diff --git a/README.md b/README.md index 9a17374..e200612 100644 --- a/README.md +++ b/README.md @@ -1 +1,139 @@ -wow \ No newline at end of file +# 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} +] +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4bc0761..9e0b2f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }, @@ -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" } @@ -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" diff --git a/src/datachoices/choices.py b/src/datachoices/choices.py index f6fe494..580a25c 100644 --- a/src/datachoices/choices.py +++ b/src/datachoices/choices.py @@ -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 @@ -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 @@ -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 diff --git a/src/datachoices/fields.py b/src/datachoices/fields.py index b523f85..29e86d1 100644 --- a/src/datachoices/fields.py +++ b/src/datachoices/fields.py @@ -11,7 +11,8 @@ 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__}' @@ -19,12 +20,12 @@ def _check_datachoices(self, choices=None): 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) @@ -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 @@ -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 @@ -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) diff --git a/tests/testapp/tests/choices/base.py b/tests/testapp/tests/choices/base.py index fb75234..84b163f 100644 --- a/tests/testapp/tests/choices/base.py +++ b/tests/testapp/tests/choices/base.py @@ -5,28 +5,93 @@ class DataChoicesTestCase(SimpleTestCase): - @staticmethod - def _make_choices_class(name, members: dict, member_type=None, **kwargs): - bases = (member_type, DataChoices) if member_type else (DataChoices,) - metaclass = type(DataChoices) - class_dict = metaclass.__prepare__(name, bases) - class_dict.update(members) - return metaclass(name, bases, class_dict, **kwargs) + __test__ = False + + def __init_subclass__(cls, **kwargs): + cls.__test__ = True - @staticmethod - def _make_members(make_member=None): + member_type = None + members_dict = { # noqa: RUF012 + 'FOO': ('foo', 'footastic', 1), + 'BAR': ('bar', 'barmazing', 2), + 'BAZ': ('baz', 'bazacular', 3), + } + + @classmethod + def _make_choices_class(cls, members_dict=None, **kwargs): members = { - 'FOO': ('foo', 'footastic'), - 'BAR': ('bar', 'barmazing'), - 'BAZ': ('baz', 'bazacular'), + member_name: cls._make_member(*args) + for member_name, args in (members_dict or cls.members_dict).items() } - if make_member: - return { - member_name: make_member(*args) for member_name, args in members.items() - } - return members + class_name = 'Choices' + bases = (cls.member_type, DataChoices) if cls.member_type else (DataChoices,) + metaclass = type(DataChoices) + class_dict = metaclass.__prepare__(class_name, bases) + class_dict.update(members) + return metaclass(class_name, bases, class_dict, **kwargs) + + @classmethod + def _make_member(cls, *args): + return args def assertChoices( self, choices_class: type[DataChoices], expected_choices: list[tuple[str, str]] ): self.assertEqual(expected_choices, choices_class.choices) + + def assertLabels( + self, choices_class: type[DataChoices], expected_labels: list[str] + ): + self.assertEqual(expected_labels, choices_class.labels) + + def assertValues(self, choices_class: type[DataChoices], expected_values: list): + self.assertEqual(expected_values, choices_class.values) + + # Shared tests + + def test_eq(self): + self.assertEqual('FOO', self._make_choices_class().FOO) + self.assertEqual('foo', self._make_choices_class(value='id').FOO) + + def test_values(self): + self.assertValues(self._make_choices_class(), ['FOO', 'BAR', 'BAZ']) + self.assertValues(self._make_choices_class(value='id'), ['foo', 'bar', 'baz']) + self.assertValues(self._make_choices_class(value=None), ['FOO', 'BAR', 'BAZ']) + + def test_invalid_values(self): + with self.assertRaisesMessage( + ValueError, + 'value property "not_a_property" is invalid for member FOO', + ): + self._make_choices_class(value='not_a_property') + + with self.assertRaisesMessage( + ValueError, + 'value property "number" of member FOO must be a non-empty string', + ): + self._make_choices_class(value='number') + + with self.assertRaisesMessage( + ValueError, + "duplicate values found in : [FOO, BAR, BAZ] -> same", + ): + self._make_choices_class(value='same') + + def test_invalid_labels(self): + with self.assertRaisesMessage( + ValueError, + 'label property "not_a_property" is invalid for member FOO', + ): + self._make_choices_class(label='not_a_property') + + with self.assertRaisesMessage( + ValueError, + 'label property "number" of member FOO must be a non-empty string', + ): + self._make_choices_class(label='number') + + with self.assertRaisesMessage( + ValueError, + "duplicate labels found in : [FOO, BAR, BAZ] -> same", + ): + self._make_choices_class(label='same') diff --git a/tests/testapp/tests/choices/test_class_instances.py b/tests/testapp/tests/choices/test_class_instances.py deleted file mode 100644 index 4a08a9c..0000000 --- a/tests/testapp/tests/choices/test_class_instances.py +++ /dev/null @@ -1,76 +0,0 @@ -from testapp.tests.choices.base import DataChoicesTestCase - - -class ClassInstancesTestCase(DataChoicesTestCase): - class SomeClass: - def __init__(self, _id, text): - self.id = _id - self.text = text - - class SomeStrClass(SomeClass): - def __str__(self): - return f'{self.text} (id: "{self.id}")' - - @classmethod - def setUpClass(cls): - members = cls._make_members(lambda *args: cls.SomeClass(*args)) - members_with_str = cls._make_members(lambda *args: cls.SomeStrClass(*args)) - - cls.Choices = cls._make_choices_class('Choices', members) - cls.ChoicesWithParams = cls._make_choices_class( - 'Choices', members, value='id', label='text' - ) - cls.ChoicesWithStr = cls._make_choices_class('ChoicesWithStr', members_with_str) - - def test_basic(self): - self.assertChoices( - self.Choices, [('FOO', 'FOO'), ('BAR', 'BAR'), ('BAZ', 'BAZ')] - ) - - def test_params(self): - self.assertChoices( - self.ChoicesWithParams, - [('foo', 'footastic'), ('bar', 'barmazing'), ('baz', 'bazacular')], - ) - - def test_label_from_str(self): - self.assertChoices( - self.ChoicesWithStr, - [ - ('FOO', 'footastic (id: "foo")'), - ('BAR', 'barmazing (id: "bar")'), - ('BAZ', 'bazacular (id: "baz")'), - ], - ) - - def test_eq(self): - self.assertEqual('FOO', self.Choices.FOO) - self.assertEqual('foo', self.ChoicesWithParams.FOO) - - def test_inner_value(self): - self.assertTrue(isinstance(self.Choices.FOO._value_, self.SomeClass)) - self.assertEqual('foo', self.Choices.FOO._value_.id) - self.assertEqual('footastic', self.Choices.FOO._value_.text) - - def test_invalid_value(self): - with self.assertRaises(ValueError) as errCtx: - members = {'FOO': self.SomeClass(42, 'footastic')} - self._make_choices_class('Choices', members, value='id') - - self.assertEqual( - "value of member FOO must be a non-empty string", - str(errCtx.exception), - ) - - def test_duplicated_value(self): - with self.assertRaises(ValueError) as errCtx: - members = { - 'FOO': self.SomeClass('foo', 'footastic'), - 'BAR': self.SomeClass('foo', 'barmazing'), - } - self._make_choices_class('Choices', members, value='id') - - self.assertEqual( - "duplicate values found in : [FOO, BAR] -> foo", - str(errCtx.exception), - ) diff --git a/tests/testapp/tests/choices/test_classes.py b/tests/testapp/tests/choices/test_classes.py index c8a9f89..6d0b2a7 100644 --- a/tests/testapp/tests/choices/test_classes.py +++ b/tests/testapp/tests/choices/test_classes.py @@ -5,78 +5,44 @@ class ClassesTestCase(DataChoicesTestCase): class FooClass: id = 'foo' text = 'footastic' + number = 1 + same = 'same' class BarClass: id = 'bar' text = 'barmazing' + number = 2 + same = 'same' class BazClass: id = 'baz' text = 'bazacular' + number = 3 + same = 'same' - @classmethod - def setUpClass(cls): - members = {'FOO': cls.FooClass, 'BAR': cls.BarClass, 'BAZ': cls.BazClass} - - cls.Choices = cls._make_choices_class('Choices', members) - cls.ChoicesWithParams = cls._make_choices_class( - 'Choices', members, value='id', label='text' - ) - cls.ChoicesNoDefaultLabel = cls._make_choices_class( - 'ChoicesNoDefaultLabel', members, label=None - ) + members_dict = { # noqa: RUF012 + 'FOO': (FooClass,), + 'BAR': (BarClass,), + 'BAZ': (BazClass,), + } - def test_basic(self): - # on classes, defaults for __name__ for the label - self.assertChoices( - self.Choices, - [('FOO', 'FooClass'), ('BAR', 'BarClass'), ('BAZ', 'BazClass')], - ) + @classmethod + def _make_member(cls, *args, **kwargs): + return args[0] - def test_params(self): - self.assertChoices( - self.ChoicesWithParams, - [('foo', 'footastic'), ('bar', 'barmazing'), ('baz', 'bazacular')], + def test_labels(self): + # on classes, defaults to __name__ + self.assertLabels( + self._make_choices_class(), ['FooClass', 'BarClass', 'BazClass'] ) - - def test_label_none(self): - self.assertChoices( - self.ChoicesNoDefaultLabel, - [('FOO', 'FOO'), ('BAR', 'BAR'), ('BAZ', 'BAZ')], + self.assertLabels(self._make_choices_class(label=None), ['FOO', 'BAR', 'BAZ']) + self.assertLabels( + self._make_choices_class(label='text'), + ['footastic', 'barmazing', 'bazacular'], ) - def test_eq(self): - self.assertEqual('FOO', self.Choices.FOO) - self.assertEqual('foo', self.ChoicesWithParams.FOO) - def test_inner_value(self): - self.assertEqual(self.FooClass, self.Choices.FOO._value_) - self.assertEqual('foo', self.Choices.FOO._value_.id) - self.assertEqual('footastic', self.Choices.FOO._value_.text) - - def test_invalid_value(self): - class NumberClass: - id = 420 - text = 'one' - - with self.assertRaises(ValueError) as errCtx: - members = {'FOO': NumberClass} - self._make_choices_class('Choices', members, value='id') - - self.assertEqual( - "value of member FOO must be a non-empty string", - str(errCtx.exception), - ) - - def test_duplicated_value(self): - with self.assertRaises(ValueError) as errCtx: - members = { - 'FOO': self.FooClass, - 'BAR': self.FooClass, - } - self._make_choices_class('Choices', members, value='id') - - self.assertEqual( - "duplicate values found in : [FOO, BAR] -> foo", - str(errCtx.exception), - ) + choices = self._make_choices_class() + self.assertEqual(self.FooClass, choices.FOO._value_) + self.assertEqual('foo', choices.FOO._value_.id) + self.assertEqual('footastic', choices.FOO._value_.text) diff --git a/tests/testapp/tests/choices/test_dataclasses.py b/tests/testapp/tests/choices/test_dataclasses.py index 36b4be6..4713003 100644 --- a/tests/testapp/tests/choices/test_dataclasses.py +++ b/tests/testapp/tests/choices/test_dataclasses.py @@ -8,72 +8,28 @@ class DataclassesTestCase(DataChoicesTestCase): class SomeClass: id: str text: str + number: int + same: str = 'same' - class SomeStrClass(SomeClass): def __str__(self): return f'{self.text} (id: "{self.id}")' - @classmethod - def setUpClass(cls): - members = cls._make_members() + member_type = SomeClass - cls.Choices = cls._make_choices_class('Choices', members, cls.SomeClass) - cls.ChoicesWithParams = cls._make_choices_class( - 'Choices', members, cls.SomeClass, value='id', label='text' + def test_labels(self): + # on instances, defaults to __str__ + self.assertLabels( + self._make_choices_class(), + ['footastic (id: "foo")', 'barmazing (id: "bar")', 'bazacular (id: "baz")'], ) - cls.ChoicesWithStr = cls._make_choices_class( - 'ChoicesWithStr', members, cls.SomeStrClass + self.assertLabels( + self._make_choices_class(label='text'), + ['footastic', 'barmazing', 'bazacular'], ) - - def test_basic(self): - self.assertChoices( - self.Choices, [('FOO', 'FOO'), ('BAR', 'BAR'), ('BAZ', 'BAZ')] - ) - - def test_params(self): - self.assertChoices( - self.ChoicesWithParams, - [('foo', 'footastic'), ('bar', 'barmazing'), ('baz', 'bazacular')], - ) - - def test_label_from_str(self): - self.assertChoices( - self.ChoicesWithStr, - [ - ('FOO', 'footastic (id: "foo")'), - ('BAR', 'barmazing (id: "bar")'), - ('BAZ', 'bazacular (id: "baz")'), - ], - ) - - def test_eq(self): - self.assertEqual(self.Choices.FOO, 'FOO') - self.assertEqual(self.ChoicesWithParams.FOO, 'foo') + self.assertLabels(self._make_choices_class(label=None), ['FOO', 'BAR', 'BAZ']) def test_inner_value(self): - self.assertTrue(isinstance(self.Choices.FOO._value_, self.SomeClass)) - self.assertEqual(self.Choices.FOO._value_.id, 'foo') - self.assertEqual(self.Choices.FOO._value_.text, 'footastic') - - def test_invalid_value(self): - with self.assertRaises(ValueError) as errCtx: - members = {'FOO': (69, 'footastic')} - self._make_choices_class('Choices', members, self.SomeClass, value='id') - - self.assertEqual( - "value of member FOO must be a non-empty string", - str(errCtx.exception), - ) - - def test_duplicated_value(self): - with self.assertRaises(ValueError) as errCtx: - members = { - 'FOO': ('foo', 'footastic'), - 'BAR': ('foo', 'barmazing'), - } - self._make_choices_class('Choices', members, self.SomeClass, value='id') - - self.assertEqual( - "duplicate values found in : [FOO, BAR] -> foo", - str(errCtx.exception), - ) + choices = self._make_choices_class() + self.assertTrue(isinstance(choices.FOO._value_, self.SomeClass)) + self.assertEqual('foo', choices.FOO._value_.id) + self.assertEqual('footastic', choices.FOO._value_.text) diff --git a/tests/testapp/tests/choices/test_dicts.py b/tests/testapp/tests/choices/test_dicts.py deleted file mode 100644 index 7648569..0000000 --- a/tests/testapp/tests/choices/test_dicts.py +++ /dev/null @@ -1,55 +0,0 @@ -from testapp.tests.choices.base import DataChoicesTestCase - - -class DictsTestCase(DataChoicesTestCase): - @classmethod - def setUpClass(cls): - members = cls._make_members(lambda id, text: {'id': id, 'text': text}) - - cls.Choices = cls._make_choices_class('Choices', members) - cls.ChoicesWithParams = cls._make_choices_class( - 'Choices', members, value='id', label='text' - ) - - def test_basic(self): - self.assertChoices( - self.Choices, [('FOO', 'FOO'), ('BAR', 'BAR'), ('BAZ', 'BAZ')] - ) - - def test_params(self): - self.assertChoices( - self.ChoicesWithParams, - [('foo', 'footastic'), ('bar', 'barmazing'), ('baz', 'bazacular')], - ) - - def test_eq(self): - self.assertEqual(self.Choices.FOO, 'FOO') - self.assertEqual(self.ChoicesWithParams.FOO, 'foo') - - def test_inner_value(self): - self.assertTrue(isinstance(self.Choices.FOO._value_, dict)) - self.assertEqual(self.Choices.FOO._value_['id'], 'foo') - self.assertEqual(self.Choices.FOO._value_['text'], 'footastic') - - def test_invalid_value(self): - with self.assertRaises(ValueError) as errCtx: - members = {'FOO': {'id': 666, 'text': 'footastic'}} - self._make_choices_class('Choices', members, value='id') - - self.assertEqual( - "value of member FOO must be a non-empty string", - str(errCtx.exception), - ) - - def test_duplicated_value(self): - with self.assertRaises(ValueError) as errCtx: - members = { - 'FOO': {'id': 'foo', 'text': 'footastic'}, - 'BAR': {'id': 'foo', 'text': 'barmazing'}, - } - self._make_choices_class('Choices', members, value='id') - - self.assertEqual( - "duplicate values found in : [FOO, BAR] -> foo", - str(errCtx.exception), - ) diff --git a/tests/testapp/tests/choices/test_instances.py b/tests/testapp/tests/choices/test_instances.py new file mode 100644 index 0000000..2680d13 --- /dev/null +++ b/tests/testapp/tests/choices/test_instances.py @@ -0,0 +1,35 @@ +from testapp.tests.choices.base import DataChoicesTestCase + + +class ClassInstancesTestCase(DataChoicesTestCase): + class SomeClass: + def __init__(self, _id, text, number): + self.id = _id + self.text = text + self.number = number + self.same = 'same' + + def __str__(self): + return f'{self.text} (id: "{self.id}")' + + @classmethod + def _make_member(cls, *args, **kwargs): + return cls.SomeClass(*args) + + def test_labels(self): + # on instances, defaults to __str__ + self.assertLabels( + self._make_choices_class(), + ['footastic (id: "foo")', 'barmazing (id: "bar")', 'bazacular (id: "baz")'], + ) + self.assertLabels( + self._make_choices_class(label='text'), + ['footastic', 'barmazing', 'bazacular'], + ) + self.assertLabels(self._make_choices_class(label=None), ['FOO', 'BAR', 'BAZ']) + + def test_inner_value(self): + choices = self._make_choices_class() + self.assertTrue(isinstance(choices.FOO._value_, self.SomeClass)) + self.assertEqual('foo', choices.FOO._value_.id) + self.assertEqual('footastic', choices.FOO._value_.text) diff --git a/tests/testapp/tests/choices/test_mappings.py b/tests/testapp/tests/choices/test_mappings.py new file mode 100644 index 0000000..5c8c02b --- /dev/null +++ b/tests/testapp/tests/choices/test_mappings.py @@ -0,0 +1,28 @@ +from testapp.tests.choices.base import DataChoicesTestCase + + +class MappingsTestCase(DataChoicesTestCase): + @classmethod + def _make_member(cls, *args, **kwargs): + _id, text, number = args + return {'id': _id, 'text': text, 'number': number, 'same': 'same'} + + def test_labels(self): + self.assertLabels(self._make_choices_class(), ['FOO', 'BAR', 'BAZ']) + self.assertLabels( + self._make_choices_class(label='text'), + ['footastic', 'barmazing', 'bazacular'], + ) + self.assertLabels(self._make_choices_class(label=None), ['FOO', 'BAR', 'BAZ']) + + def test_inner_value(self): + choices = self._make_choices_class() + self.assertTrue(isinstance(choices.FOO._value_, dict)) + self.assertEqual( + 'foo', + choices.FOO._value_['id'], + ) + self.assertEqual( + 'footastic', + choices.FOO._value_['text'], + )