Skip to content

feat: Add support for choices in template components. #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 27, 2025
Merged

Conversation

fsbraun
Copy link
Member

@fsbraun fsbraun commented May 27, 2025

The choices parameter of a ChoiceField requires a list of 2-tuples (value, verbose_string). This PR extends the split template tag to turn substring of the form "Verbose label <value>" into 2-tuples:

choices="Dark mode <bg-dark>|Light mode <bg-light>"|split

lets choices become

[
    ("bg-dark", "Dark mode"),
    ("bg-light", "Light mode"),
]

Summary by Sourcery

Enable parsing of choice definitions in the split template filter to support Django ChoiceField syntax.

New Features:

  • Extend the split filter to convert strings in the format “Label ” into (value, label) tuples for use as form field choices.

Enhancements:

  • Introduce a helper function _to_tuple_if_needed with regex matching to extract verbose labels and values.
  • Add __future__.annotations import and update type hints on the split filter.

Tests:

  • Add unit tests covering choice parsing behavior and edge cases for the enhanced split filter.
Copy link
Contributor

sourcery-ai bot commented May 27, 2025

Reviewer's Guide

Extend the split template filter to support Django form choice syntax by introducing a regex‐based helper that converts “Label ” substrings into (value, label) tuples, updating the filter’s signature and documentation accordingly, and adding unit tests to cover the new behavior and edge cases.

Sequence diagram for the updated split filter operation

sequenceDiagram
    participant TE as TemplateEngine
    participant S as split_filter
    participant TFN as _to_tuple_if_needed_function

    TE->>S: invoke split(value, delimiter)
    S->>S: value.split(delimiter) results in items_list
    loop for each item in items_list
        S->>TFN: _to_tuple_if_needed(item)
        alt item matches "Verbose label <value>" format
            TFN-->>S: return (value_extracted, label_extracted)
        else item does not match
            TFN-->>S: return item_as_string
        end
    end
    S-->>TE: return list_of_processed_items
Loading

Class diagram for changes in cms_component.py

classDiagram
    class `cms_component.py` {
        +split(value: str, delimiter: str = "|") : list /* Python type: list[str | tuple[str,str]] */
        -_to_tuple_if_needed(value: str) : object /* Python type: str | tuple[str,str] */
    }
Loading

File-Level Changes

Change Details Files
Introduce _to_tuple_if_needed helper for parsing verbose labels into choice tuples
  • Import re and enable future annotations
  • Implement regex match for “Label ” pattern
  • Return either a (value, label) tuple or the original string
djangocms_frontend/templatetags/cms_component.py
Enhance split filter to apply tuple conversion to substrings
  • Update return type annotation to include tuple list
  • Map each split item through _to_tuple_if_needed
  • Adjust docstring to describe tuple support
djangocms_frontend/templatetags/cms_component.py
Add unit tests for tuple conversion and edge cases in split filter
  • Test choice syntax with and without spaces
  • Verify correct handling of mixed and invalid formats
  • Ensure backward compatibility for simple splits
tests/test_autocomponent.py
Update documentation for new choice tuple syntax in templates
  • Document the “Verbose label ” format
  • Provide examples showing tuple output in the split tag guide
docs/source/tutorial/template_components.rst
docs/source/tutorial/custom_components.rst

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

codecov bot commented May 27, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 88.68%. Comparing base (3cd8767) to head (45650d6).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #282      +/-   ##
==========================================
+ Coverage   88.66%   88.68%   +0.02%     
==========================================
  Files         124      124              
  Lines        3378     3386       +8     
  Branches      288      289       +1     
==========================================
+ Hits         2995     3003       +8     
  Misses        265      265              
  Partials      118      118              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
@fsbraun fsbraun merged commit 647d1d0 into master May 27, 2025
36 checks passed
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey @fsbraun - I've reviewed your changes - here's some feedback:

  • The docstring for _to_tuple_if_needed says it returns a single-element tuple when there’s no match, but the code actually returns the original string—please update the docstring to match the behavior.
  • You might simplify the return type annotation on split to list[str | tuple[str, str]] rather than a union of two separate list types for clearer typing.
Here's what I looked at during the review
  • 🟡 General issues: 3 issues found
  • 🟢 Security: all looks good
  • 🟡 Testing: 1 issue found
  • 🟢 Complexity: all looks good
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
@@ -49,10 +51,29 @@ def field(context: template.Context, field_name: str, field_type: forms.Field, *
return ""


def _to_tuple_if_needed(value: str) -> str | tuple[str, str]:
Copy link
Contributor

Choose a reason for hiding this comment

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

issue: Docstring for _to_tuple_if_needed misrepresents return value

Update the docstring to reflect that non-matches return a plain string, or modify the function to return a tuple in those cases for consistency.

@register.filter
def split(value: str, delimiter: str = "|") -> list[str]:
def split(value: str, delimiter: str = "|") -> list[str] | list[tuple[str, str]]:
Copy link
Contributor

Choose a reason for hiding this comment

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

issue: Return type annotation for split is too narrow

The annotation should be list[str | tuple[str, str]] to accurately reflect that the list may contain both types, not a union of two separate list types.

@@ -69,7 +69,23 @@ def test_split_template_tag(self):
self.assertEqual(split("hero.html"), ["hero.html"])
Copy link
Contributor

Choose a reason for hiding this comment

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

nitpick (testing): This test case for split("hero.html") appears to be a duplicate.

This assertion duplicates the one above; consider removing it unless it tests a different scenario.

Comment on lines +281 to +284
.. note::

For translators it is important to know, that they **should not translate** the value in angle brackets.
The German translation of the above example string might be ``Rot <red>|Grün <green>|Standard <blue>``.
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (typo): Typo: extraneous comma after "know".

Remove the comma after "know" for correct grammar: "For translators it is important to know that they should not translate the value in angle brackets."

Suggested change
.. note::
For translators it is important to know, that they **should not translate** the value in angle brackets.
The German translation of the above example string might be ``Rot <red>|Grün <green>|Standard <blue>``.
.. note::
For translators it is important to know that they **should not translate** the value in angle brackets.
The German translation of the above example string might be ``Rot <red>|Grün <green>|Standard <blue>``.
Comment on lines +65 to +67
match = re.match(r"^(.*?)\s*<([^>]+)>\s?$", value)
if match:
return (match.group(2).strip(), match.group(1).strip())
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (code-quality): We've found these issues:

Suggested change
match = re.match(r"^(.*?)\s*<([^>]+)>\s?$", value)
if match:
return (match.group(2).strip(), match.group(1).strip())
if match := re.match(r"^(.*?)\s*<([^>]+)>\s?$", value):
return match[2].strip(), match[1].strip()
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey @fsbraun - I've reviewed your changes - here's some feedback:

  • The _to_tuple_if_needed docstring says non‐matching strings are returned as a single‐element tuple but the code actually returns the raw string—please update the doc or implementation to match.
  • The return type on split (list[str] | list[tuple[str, str]]) doesn’t allow for mixed lists of strings and tuples; consider using list[str | tuple[str, str]] to be accurate.
  • The current regex will misinterpret strings with extra < or > characters—either refine the pattern for edge cases or document its limitations explicitly.
Here's what I looked at during the review
  • 🟡 General issues: 4 issues found
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 1 issue found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Comment on lines +65 to +67
match = re.match(r"^(.*?)\s*<([^>]+)>\s?$", value)
if match:
return (match.group(2).strip(), match.group(1).strip())
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (performance): Consider precompiling the regex pattern or using re.fullmatch

Compiling the regex once at module scope improves efficiency. Also, re.fullmatch can simplify the pattern by removing the need for explicit anchors.

Suggested change
match = re.match(r"^(.*?)\s*<([^>]+)>\s?$", value)
if match:
return (match.group(2).strip(), match.group(1).strip())
import re
_EMAIL_CHOICE_PATTERN = re.compile(r"(.*?)\s*<([^>]+)>\s?")
match = _EMAIL_CHOICE_PATTERN.fullmatch(value)
if match:
return (match.group(2).strip(), match.group(1).strip())

Returns:
tuple[str, str]: A tuple containing the two parts of the string if it contains a delimiter,
otherwise returns the original string as a single-element tuple.
Copy link
Contributor

Choose a reason for hiding this comment

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

issue: Docstring return description is inaccurate

Update the docstring to reflect that non-matching inputs return the raw string, or modify the code to return a single-element tuple as described.

Comment on lines 71 to +72
@register.filter
def split(value: str, delimiter: str = "|") -> list[str]:
def split(value: str, delimiter: str = "|") -> list[str] | list[tuple[str, str]]:
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Type annotation does not reflect mixed element types

Annotate the return type as list[str | tuple[str, str]] to accurately represent mixed element types.

Suggested change
@register.filter
def split(value: str, delimiter: str = "|") -> list[str]:
def split(value: str, delimiter: str = "|") -> list[str] | list[tuple[str, str]]:
@register.filter
def split(value: str, delimiter: str = "|") -> list[str | tuple[str, str]]:
@@ -61,4 +82,5 @@ def split(value: str, delimiter: str = "|") -> list[str]:
Returns:
list[str]: A list of substrings obtained by splitting the input string using the delimiter.
Copy link
Contributor

Choose a reason for hiding this comment

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

issue: Update Returns doc to include tuple cases

Please revise the Returns section to document that the function may also return tuples, not just a list of strings.

@@ -49,10 +51,29 @@ def field(context: template.Context, field_name: str, field_type: forms.Field, *
return ""


def _to_tuple_if_needed(value: str) -> str | tuple[str, str]:
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider replacing the regex-based parsing in _to_tuple_if_needed with simple string methods to reduce complexity and remove the need for the re import.

Suggested change
def _to_tuple_if_needed(value: str) -> str | tuple[str, str]:
You can remove the regex import and simplify `_to_tuple_if_needed` using built-in string methods. For example:
```python
def _to_tuple_if_needed(item: str) -> str | tuple[str, str]:
# Split on the last '<'; if not found or no '>' after it, just return the original
name, sep, rest = item.rpartition('<')
if not sep or '>' not in rest:
return item
# Extract the value up to the first '>' and strip whitespace
val, _sep2, _ = rest.partition('>')
return (val.strip(), name.strip())

This removes import re and avoids the regex overhead.
Optionally, you can inline the same logic right in the filter to keep everything in one place:

@register.filter
def split(value: str, delimiter: str = "|") -> list[str] | list[tuple[str, str]]:
    parts = value.split(delimiter)
    out = []
    for item in parts:
        name, sep, rest = item.rpartition('<')
        if sep and '>' in rest:
            val, _sep2, _ = rest.partition('>')
            out.append((val.strip(), name.strip()))
        else:
            out.append(item.strip())
    return out

Both approaches preserve your functionality but reduce complexity by using simple string operations.

Comment on lines +65 to +67
match = re.match(r"^(.*?)\s*<([^>]+)>\s?$", value)
if match:
return (match.group(2).strip(), match.group(1).strip())
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (code-quality): We've found these issues:

Suggested change
match = re.match(r"^(.*?)\s*<([^>]+)>\s?$", value)
if match:
return (match.group(2).strip(), match.group(1).strip())
if match := re.match(r"^(.*?)\s*<([^>]+)>\s?$", value):
return match[2].strip(), match[1].strip()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
1 participant