Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,139 @@
wow
# django datachoices

---
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![PyPI - Version](https://img.shields.io/pypi/v/django-datachoices)](https://pypi.org/project/django-datachoices/)


#### Utility for Django to bind field choices with rich data.

Provides two core classes — **DataChoices** and **DataChoiceField** — designed to simplify handling model field choices that are tied to complex data.

## Install
```sh
pip install django-datachoices
```

## Usage
```python
from datachoices import DataChoices, DataChoiceField
from django.db import models


class GameChoices(DataChoices, label="title"):
SM64 = {"title": "Super Mario 64", "id": "NAAE", "year": 1996}
SMS = {"title": "Super Mario Sunshine", "id": "GMSP01", "year": 2002}
SMG = {"title": "Super Mario Galaxy", "id": "RMGP01", "year": 2007}

class MyModel(models.Model):
game = DataChoiceField(choices=GameChoices)
```
```python
> instance = MyModel.objects.create(game=GameChoices.SMS)

> instance.game
"SMS"
> instance.get_game_display()
"Super Mario Sunshine"
> instance.get_game_data()
{"title": "Super Mario Sunshine", "id": "GMSP01", "year": 2002}
```

### label / value attribute
The **label** (readable representation, e.g., on select widgets) and the **value** (the actual field value for the database) attributes are set using class arguments:
```python
class GameChoices(DataChoices, label="title", value="id"):
```
```python
> instance.game
"GMSP01"
```
If not specified, the member name (*SM64*, *SMS* or *SMG* in this example) is used as the value. This is fine for most cases.
However, the label is mostly more interesting for display purposes. If not specified, this defaults to *\_\_str__*, *\_\_name__* or the member name (depending on the type).


```python
from datachoices import DataChoices, DataChoiceField
from django.db import models

from my_app import FooClass, BarClass


class HandlerClassChoices(DataChoices):
FOO = FooClass
BAR = BarClass

class MyModel(models.Model):
handler_class = DataChoiceField(choices=HandlerClassChoices)
```
```python
> instance = MyModel.objects.create(handler_class=HandlerClassChoices.BAR)

> instance.get_handler_class_display()
"BarClass"
```

### Member types
This package is currently tested using **dictionaries**, **classes**, **class instances** and **@dataclass instances** as member types.


```python
# dicts

class DictChoices(DataChoices):
FOO = {"title": "Foo", "number": 1}
BAR = {"title": "Bar", "number": 2}

# classes

class ClassChoices(DataChoices):
FOO = FooClass
BAR = BarClass

# class instances

class InstanceChoices(DataChoices):
FOO = SomeClass("Foo")
BAR = SomeClass("Bar")

# instances using dataclass mixin syntax

@dataclass
class SomeMixin:
title: str
number: int

class DataclassInstanceChoices(SomeMixin, DataChoices):
FOO = "Foo", 1
BAR = "Bar", 2
```

### Multiple choice fields
This package also provides a **DataChoiceArrayField** class that can be used to create multiple choice fields.
Just be aware, this uses django's *django.contrib.postgres.fields.**ArrayField***. So to use it, you need a postgres database.

```python
from datachoices import DataChoices, DataChoiceArrayField
from django.db import models


class GameChoices(DataChoices, label="title"):
SM64 = {"title": "Super Mario 64", "id": "NAAE", "year": 1996}
SMS = {"title": "Super Mario Sunshine", "id": "GMSP01", "year": 2002}
SMG = {"title": "Super Mario Galaxy", "id": "RMGP01", "year": 2007}

class MyModel(models.Model):
games = DataChoiceArrayField(choices=GameChoices)
```
```python
> instance = MyModel.objects.create(games=[GameChoices.SM64, GameChoices.SMG])

> instance.get_games_display()
"Super Mario 64 & Super Mario Galaxy"
> instance.get_games_data()
[
{"title": "Super Mario Sunshine", "id": "GMSP01", "year": 2002},
{"title": "Super Mario Galaxy", "id": "RMGP01", "year": 2007}
]
```
20 changes: 15 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "django-datachoices"
description = "A utility for Django to bind complex data to field choices."
description = "Utility for Django to bind field choices with rich data."
version = "0.1.3"
authors = [
{ name="blu14x" },
Expand All @@ -10,7 +10,19 @@ requires-python = ">=3.6"
classifiers = [
"Topic :: Utilities",
"Framework :: Django",
"Programming Language :: Python :: 3",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Framework :: Django :: 6.0",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
]
license = { file = "LICENSE" }

Expand All @@ -28,14 +40,12 @@ where = ["src"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "settings"
testpaths = ["tests"]
pythonpath = ["tests"]
pythonpath = ["src", "tests"]
addopts = ["--tb=short", "--cov-branch", "--cov=src", "--cov-report=xml"]

[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
version_variables = ["src/datachoices/__init__.py:__version__"]
major_on_zero = false
allow_zero_version = true
commit_message = "v{version}\n\nAutomatically generated by python-semantic-release"
build_command = "python -m pip install --upgrade build && python -m build"

Expand Down
97 changes: 60 additions & 37 deletions src/datachoices/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,59 @@


def validate_data_choices_enum(enumeration): # noqa: D103
value_member_map = {}
for name, member in enumeration.__members__.items():
if not isinstance(member.value, str) or not member.value:
value_map = {}
label_map = {}

def _check_prop_is_valid(
member_name,
member_value,
prop_map,
prop_name: str,
prop_attr: str,
):
try:
value = getattr(member_value, prop_name)
except (AttributeError, KeyError):
raise ValueError(
f'{prop_name} property "{prop_attr}" is invalid for {enumeration} member {member_name}'
)
if not value or not isinstance(value, str):
raise ValueError(
f'{prop_name} property "{prop_attr}" of {enumeration} member {member_name} must be a non-empty string'
)
prop_map.setdefault(value, []).append(member_name)

def _check_prop_duplicates(prop_map, prop_name: str):
duplicate_values = {
value: members for value, members in prop_map.items() if len(members) > 1
}
if duplicate_values:
alias_details = ', '.join(
[
f'[{", ".join(aliases)}] -> {value}'
for value, aliases in duplicate_values.items()
]
)
raise ValueError(
f'value of {enumeration} member {name} must be a non-empty string'
f'duplicate {prop_name}s found in {enumeration}: {alias_details}'
)

value_member_map.setdefault(member.value, []).append(name)
duplicates = {
value: members
for value, members in value_member_map.items()
if len(members) > 1
}
if duplicates:
alias_details = ', '.join(
[
f'[{", ".join(aliases)}] -> {value}'
for value, aliases in duplicates.items()
]
for name, member in enumeration.__members__.items():
_check_prop_is_valid(
name, member, value_map, 'value', enumeration.__member_value__
)
_check_prop_is_valid(
name, member, label_map, 'label', enumeration.__member_label__
)
raise ValueError(f'duplicate values found in {enumeration}: {alias_details}')

_check_prop_duplicates(value_map, 'value')
_check_prop_duplicates(label_map, 'label')

return enumeration


class DataChoicesMeta(ChoicesType):
"""Modified ChoicesType metaclass for DataChoices."""
"""Metaclass for creating DataChoices."""

def __new__( # noqa: D102
metacls, classname, bases, class_dict, label='', value='', **kwargs
Expand All @@ -49,7 +75,7 @@ def __new__( # noqa: D102


class DataChoices(Choices, metaclass=DataChoicesMeta):
"""Class for creating enumerated choices bound to dataclass instances."""
"""Class for creating enumerated choices of complex data."""

__member_label__: str
__member_value__: str
Expand All @@ -60,28 +86,25 @@ def __init_subclass__(cls, **kwargs): # noqa: D105

@enum_property
def value(self): # noqa: D102
return self._resolve_choice_property(self.__member_value__)
attr = self.__member_value__
return self._resolve_choice_prop(attr)

@enum_property
def label(self): # noqa: D102
default_attr = None
if isinstance(self._value_, type):
default_attr = '__name__'
elif type(self._value_).__str__ is not object.__str__:
default_attr = '__str__'

return self._resolve_choice_property(self.__member_label__, default_attr)

def _resolve_choice_property(self, attr=None, default_attr=None):
if attr is None:
return self._name_
if isinstance(self._value_, Mapping):
return self._value_.get(attr) if attr else self._name_

attr = self.__member_label__
if attr == '':
if isinstance(self._value_, type):
attr = '__name__'
elif type(self._value_).__str__ is not object.__str__:
attr = '__str__'
return self._resolve_choice_prop(attr)

def _resolve_choice_prop(self, attr: None | str):
if attr:
attr_value = getattr(self._value_, attr)
elif default_attr:
attr_value = getattr(self._value_, default_attr, self._name_)
if isinstance(self._value_, Mapping):
attr_value = self._value_[attr]
else:
attr_value = getattr(self._value_, attr)
else:
attr_value = self._name_
return attr_value() if callable(attr_value) else attr_value
27 changes: 16 additions & 11 deletions src/datachoices/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@


class DataChoicesFieldMixin: # noqa: D101
def _check_datachoices(self, choices=None):
@staticmethod
def _check_datachoices(choices=None):
if not isinstance(choices, type) or not issubclass(choices, DataChoices):
raise TypeError(
f'Must provide a DataChoices subclass for choices. Got {type(choices).__name__}'
)
return choices

def contribute_to_class(self, cls, name, **kwargs): # noqa: D102
display_pname = f'get_{name}_display'
if display_pname not in cls.__dict__:
setattr(cls, display_pname, partialmethod(_get_FIELD_display, field=self))
data_pname = f'get_{name}_data'
if data_pname not in cls.__dict__:
setattr(cls, data_pname, partialmethod(_get_FIELD_data, field=self))
display_prop = f'get_{name}_display'
data_prop = f'get_{name}_data'
if display_prop not in cls.__dict__:
setattr(cls, display_prop, partialmethod(_get_FIELD_display, field=self))
if data_prop not in cls.__dict__:
setattr(cls, data_prop, partialmethod(_get_FIELD_data, field=self))
super().contribute_to_class(cls, name, **kwargs)


Expand All @@ -50,8 +51,8 @@ def _get_FIELD_data(instance, field):
values = [values] if values else []
many = False

dc_field = getattr(field, 'base_field', field)
data_choices = getattr(dc_field, 'data_choices', None)
field = getattr(field, 'base_field', field)
data_choices = getattr(field, 'data_choices', None)
if not data_choices:
return [] if many else None

Expand All @@ -64,7 +65,9 @@ def _get_FIELD_data(instance, field):
return getattr(members[0], '_value_') if members else None


class DataChoiceField(DataChoicesFieldMixin, models.CharField): # noqa: D101
class DataChoiceField(DataChoicesFieldMixin, models.CharField):
"""Model field for DataChoices."""

def __init__(self, *args, choices=None, **kwargs): # noqa: D107
self.data_choices = self._check_datachoices(choices)
kwargs['choices'] = self.data_choices.choices
Expand All @@ -85,7 +88,9 @@ def to_python(self, value): # noqa: D102
return value if isinstance(value, types) or value is None else str(value)


class DataChoiceArrayField(DataChoicesFieldMixin, ArrayField): # noqa: D101
class DataChoiceArrayField(DataChoicesFieldMixin, ArrayField):
"""Array model field for DataChoices."""

def __init__(self, choices=None, **kwargs): # noqa: D107
kwargs['base_field'] = DataChoiceField(choices=self._check_datachoices(choices))
super().__init__(**kwargs)
Expand Down
Loading