Skip to content

Commit

Permalink
Merge pull request #21 from CarliJoy/staging
Browse files Browse the repository at this point in the history
Define unit register central and allow unit_choice propagation
  • Loading branch information
CarliJoy committed Nov 29, 2020
2 parents 4cb8942 + 53e7672 commit 9ffcc59
Show file tree
Hide file tree
Showing 15 changed files with 185 additions and 67 deletions.
9 changes: 0 additions & 9 deletions .isort.cfg

This file was deleted.

2 changes: 2 additions & 0 deletions CHANGELOG.rst
Expand Up @@ -4,6 +4,8 @@ Changelog

Version 0.5
===========
- API Change: Units are now defined project wide in settings and not by defining ureg
for Fields
- Change of Maintainer to `Carli* Freudenberg`_
- Ported code to work with current version of Django (2.2., 3.0, 3.2) and Python (3.6 - 3.9)
- added test for merge requests
Expand Down
38 changes: 26 additions & 12 deletions README.md
Expand Up @@ -56,7 +56,7 @@ If your base unit is atomic (i.e. can be represented by an integer), you may als

You can also pass Quantity objects to be stored in models. These are automatically converted to the units defined for the field ( but can be converted to something else when retrieved of course ).

>> from quantityfield import ureg
>> from quantityfield.units import ureg
>> Quantity = ureg.Quantity
>> pounds = Quantity(500 * ureg.pound)
>> bale = HayBale.objects.create(weight=pounds)
Expand All @@ -70,23 +70,37 @@ Use the inbuilt form field and widget to allow input of quantity values in diffe
class HayBaleForm(forms.Form):
weight = QuantityFormField(base_units='gram', unit_choices=['gram', 'ounce', 'milligram'])

The form will render a float input and a select widget to choose the units. Whenever cleaned_data is presented from the above form the weight field value will be a Quantity with the units set to grams ( values are converted from the units input by the user ).
The form will render a float input and a select widget to choose the units.
Whenever cleaned_data is presented from the above form the weight field value will be a
Quantity with the units set to grams (values are converted from the units input by the user ).
You also can add the `unit_choices` directly to the `ModelField`. It will be propagated
correctly.

For comparative lookups, query values will be coerced into the correct units when comparing values, this means that comparing 1 ounce to 1 tonne should yield the correct results.
For comparative lookups, query values will be coerced into the correct units when comparing values,
this means that comparing 1 ounce to 1 tonne should yield the correct results.

less_than_a_tonne = HayBale.objects.filter(weight__lt=Quantity(2000 * ureg.pound))

You can also use a custom Pint unit registry:
You can also use a custom Pint unit registry in your project `settings.py`

# app/models.py
# project/settings.py

from django.db import models
from quantityfield import DeconstructibleUnitRegistry
from quantityfield.fields import QuantityField
from pint import UnitRegistry

my_ureg = DeconstructibleUnitRegistry('your_units.txt')
# django-pint will set the DJANGO_PINT_UNIT_REGISTER automatically
# as application_registry
DJANGO_PINT_UNIT_REGISTER = UnitRegistry('your_units.txt')
DJANGO_PINT_UNIT_REGISTER.define('beer_bootle_weight = 0.8 * kg = beer')

class HayBale(models.Model):
custom_unit = QuantityField('tonne', ureg=my_ureg)
# app/models.py

Note that in order to use Django's migrations with a custom unit registry, all unit info must be passed to the UnitRegistry constructor via the textfile method shown above. Calls to `.define(...)` aren't considered by Django's migration framework.
class HayBale(models.Model):
# now you can use your custom units in your models
custom_unit = QuantityField('beer')

Note: As the [documentation from pint](https://pint.readthedocs.io/en/latest/tutorial.html#using-pint-in-your-projects)
states quite clearly: For each project there should be only one unit registry.
Please note that if you change the unit registry for an already created project with
data in a database, you could invalidate your data! So be sure you know what you are
doing!
Still only adding units should be okay.
10 changes: 10 additions & 0 deletions setup.cfg
Expand Up @@ -135,6 +135,16 @@ exclude =
docs/conf.py
.venv*

[isort]
profile=black
skip=.tox,.venv*,build,dist
known_standard_library=setuptools,pkg_resources
known_test=pytest
known_django=django
known_first_party=django_pint
known_pandas=pandas,numpy
sections=FUTURE,STDLIB,TEST,DJANGO,THIRDPARTY,PANDAS,FIRSTPARTY,LOCALFOLDER

[pyscaffold]
# PyScaffold's parameters when the project was created.
# This will be used when updating. Do not change!
Expand Down
26 changes: 12 additions & 14 deletions src/quantityfield/__init__.py
@@ -1,14 +1,12 @@
__version__ = "0.4"

from django.utils.deconstruct import deconstructible

from pint import UnitRegistry


@deconstructible
class DeconstructibleUnitRegistry(UnitRegistry):
"""Make UnitRegistry compatible with Django migrations by implementing the
deconstruct() method."""


ureg = DeconstructibleUnitRegistry()
from pkg_resources import DistributionNotFound, get_distribution

try:
# Change here if project is renamed and does not equal the package name
dist_name = "django-pint"
__version__ = get_distribution(dist_name).version
except DistributionNotFound: # pragma: no cover
# We don't expect this to be executed, as this would mean the configuration
# for the python module is wrong
__version__ = "unknown"
finally:
del get_distribution, DistributionNotFound
44 changes: 29 additions & 15 deletions src/quantityfield/fields.py
Expand Up @@ -5,11 +5,13 @@
from django.utils.translation import gettext_lazy as _

import warnings
from pint import DimensionalityError, Quantity
from pint import Quantity
from typing import List, Optional

from quantityfield.exceptions import PrecisionLoss
from quantityfield.helper import check_matching_unit_dimension

from . import ureg as default_ureg
from .units import ureg
from .widgets import QuantityWidget


Expand All @@ -35,19 +37,36 @@ class QuantityFieldMixin(object):

"""A Django Model Field that resolves to a pint Quantity object"""

def __init__(self, base_units=None, *args, **kwargs):
if not base_units:
def __init__(
self, base_units: str, *args, unit_choices: Optional[List[str]] = None, **kwargs
):
"""
Create a Quantity field
:param base_units: Unit description of base unit
:param unit_choices: If given the possible unit choices with the same
dimension like the base_unit
"""
if not isinstance(base_units, str):
raise ValueError(
'QuantityField must be defined with base units, eg: "gram"'
)

self.ureg = kwargs.pop("ureg", default_ureg)
self.ureg = ureg

# we do this as a way of raising an exception if some crazy unit was supplied.
unit = getattr(self.ureg, base_units) # noqa: F841

# if we've not hit an exception here, we should be all good
self.base_units = base_units

if unit_choices is None:
self.unit_choices: List[str] = [self.base_units]
else:
self.unit_choices = unit_choices

# Check if all unit_choices are valid
check_matching_unit_dimension(self.ureg, self.base_units, self.unit_choices)

super(QuantityFieldMixin, self).__init__(*args, **kwargs)

@property
Expand All @@ -57,7 +76,7 @@ def units(self):
def deconstruct(self):
name, path, args, kwargs = super(QuantityFieldMixin, self).deconstruct()
kwargs["base_units"] = self.base_units
kwargs["ureg"] = self.ureg
kwargs["unit_choices"] = self.unit_choices
return name, path, args, kwargs

def get_prep_value(self, value):
Expand Down Expand Up @@ -119,8 +138,8 @@ def get_prep_lookup(self, lookup_type, value):
def formfield(self, **kwargs):
defaults = {
"form_class": self.form_field_class,
"ureg": self.ureg,
"base_units": self.base_units,
"unit_choices": self.unit_choices,
}
defaults.update(kwargs)
return super(QuantityFieldMixin, self).formfield(**defaults)
Expand All @@ -135,9 +154,9 @@ class QuantityFormFieldMixin(object):
to_number_type = NotImplemented

def __init__(self, *args, **kwargs):
self.ureg = kwargs.pop("ureg", default_ureg)
self.ureg = ureg
self.base_units = kwargs.pop("base_units", None)
if not self.base_units:
if self.base_units is None:
raise ValueError(
"QuantityFormField requires a base_units kwarg of a "
"single unit type (eg: grams)"
Expand All @@ -146,12 +165,7 @@ def __init__(self, *args, **kwargs):
if self.base_units not in self.units:
self.units.append(self.base_units)

base_unit = getattr(self.ureg, self.base_units)

for _unit in self.units:
unit = getattr(self.ureg, _unit)
if unit.dimensionality != base_unit.dimensionality:
raise DimensionalityError(base_unit, unit)
check_matching_unit_dimension(self.ureg, self.base_units, self.units)

kwargs["widget"] = kwargs.get(
"widget",
Expand Down
19 changes: 19 additions & 0 deletions src/quantityfield/helper.py
@@ -0,0 +1,19 @@
from pint import DimensionalityError, UnitRegistry
from typing import List


def check_matching_unit_dimension(
ureg: UnitRegistry, base_units: str, units_to_check: List[str]
) -> None:
"""
Check if all units_to_check have the same Dimension like the base_units
If not
:raise DimensionalityError
"""

base_unit = getattr(ureg, base_units)

for unit_string in units_to_check:
unit = getattr(ureg, unit_string)
if unit.dimensionality != base_unit.dimensionality:
raise DimensionalityError(base_unit, unit)
11 changes: 11 additions & 0 deletions src/quantityfield/settings.py
@@ -0,0 +1,11 @@
__version__ = "0.4"
from django.conf import settings

from pint import UnitRegistry, set_application_registry

# Define default unit register
DJANGO_PINT_UNIT_REGISTER = getattr(
settings, "DJANGO_PINT_UNIT_REGISTER", UnitRegistry()
)
# Set as default application registry for i.e. for pickle
set_application_registry(DJANGO_PINT_UNIT_REGISTER)
4 changes: 4 additions & 0 deletions src/quantityfield/units.py
@@ -0,0 +1,4 @@
from .settings import DJANGO_PINT_UNIT_REGISTER

# The unit register that was defined in the settings (shortcurt)
ureg = DJANGO_PINT_UNIT_REGISTER
6 changes: 3 additions & 3 deletions src/quantityfield/widgets.py
Expand Up @@ -2,12 +2,12 @@

import re

from . import ureg as default_ureg
from .units import ureg


class QuantityWidget(MultiWidget):
def __init__(self, *, attrs=None, base_units=None, allowed_types=None, **kwargs):
self.ureg = kwargs.pop("ureg", default_ureg)
def __init__(self, *, attrs=None, base_units=None, allowed_types=None):
self.ureg = ureg
choices = self.get_choices(allowed_types)
self.base_units = base_units
attrs = attrs or {}
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Expand Up @@ -9,10 +9,16 @@

import django

from pint import UnitRegistry


def pytest_configure(config):
from django.conf import settings

custom_ureg = UnitRegistry()
custom_ureg.define("custom = [custom]")
custom_ureg.define("kilocustom = 1000 * custom")

settings.configure(
DATABASES={
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}
Expand All @@ -37,5 +43,6 @@ def pytest_configure(config):
"quantityfield",
"tests.dummyapp",
],
DJANGO_PINT_UNIT_REGISTER=custom_ureg,
)
django.setup()
13 changes: 6 additions & 7 deletions tests/dummyapp/models.py
@@ -1,17 +1,11 @@
from django.db import models

from pint import UnitRegistry

from quantityfield.fields import (
BigIntegerQuantityField,
IntegerQuantityField,
QuantityField,
)

custom_ureg = UnitRegistry()
custom_ureg.define("custom = [custom]")
custom_ureg.define("kilocustom = 1000 * custom")


class HayBale(models.Model):
name = models.CharField(max_length=20)
Expand All @@ -26,4 +20,9 @@ class EmptyHayBale(models.Model):


class CustomUregHayBale(models.Model):
custom = QuantityField("custom", ureg=custom_ureg, null=True)
# Custom is defined in settings in conftest.py
custom = QuantityField("custom", null=True)


class ChoicesDefinedInModel(models.Model):
weight = QuantityField("kilogram", unit_choices=["milligram", "pounds"])
25 changes: 20 additions & 5 deletions tests/test_field.py
Expand Up @@ -8,9 +8,9 @@
import warnings
from pint import DimensionalityError, UndefinedUnitError, UnitRegistry

from quantityfield import ureg
from quantityfield.fields import QuantityField
from tests.dummyapp.models import CustomUregHayBale, EmptyHayBale, HayBale, custom_ureg
from quantityfield.units import ureg
from tests.dummyapp.models import CustomUregHayBale, EmptyHayBale, HayBale

Quantity = ureg.Quantity

Expand All @@ -25,9 +25,24 @@ def test_fails_with_unknown_units(self):
test_crazy_units = QuantityField("zinghie") # noqa: F841

def test_base_units_is_required(self):
with self.assertRaises(ValueError):
with self.assertRaises(TypeError):
no_units = QuantityField() # noqa: F841

def test_base_units_set_with_name(self):
okay_units = QuantityField(base_units="meter") # noqa: F841

def test_base_units_are_invalid(self):
with self.assertRaises(ValueError):
wrong_units = QuantityField(None) # noqa: F841

def test_unit_choices_must_be_valid_units(self):
with self.assertRaises(UndefinedUnitError):
QuantityField(base_units="mile", unit_choices=["gunzu"])

def test_unit_choices_must_match_base_dimensionality(self):
with self.assertRaises(DimensionalityError):
QuantityField(base_units="gram", unit_choices=["meter", "ounces"])


@pytest.mark.django_db
class TestFieldSave(TestCase):
Expand All @@ -40,7 +55,7 @@ def setUp(self):
self.heaviest = HayBale.objects.create(weight=1000, name="heaviest")
EmptyHayBale.objects.create(name="Empty")
CustomUregHayBale.objects.create(custom=5)
CustomUregHayBale.objects.create(custom=5 * custom_ureg.kilocustom)
CustomUregHayBale.objects.create(custom=5 * ureg.kilocustom)

def test_stores_value_in_base_units(self):
item = HayBale.objects.get(name="ounce")
Expand Down Expand Up @@ -152,7 +167,7 @@ def tearDown(self):

def test_custom_ureg(self):
obj = CustomUregHayBale.objects.first()
self.assertIsInstance(obj.custom, custom_ureg.Quantity)
self.assertIsInstance(obj.custom, ureg.Quantity)
self.assertEqual(str(obj.custom), "5.0 custom")

obj = CustomUregHayBale.objects.last()
Expand Down

0 comments on commit 9ffcc59

Please sign in to comment.