django-pint 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ # Change here if project is renamed and does not equal the package name
5
+ dist_name = "django-pint"
6
+ __version__ = version(dist_name)
7
+ except PackageNotFoundError: # pragma: no cover
8
+ __version__ = "unknown"
9
+ finally:
10
+ del version, PackageNotFoundError
11
+
12
+ from quantityfield import fields, helper, settings, units, widgets
13
+
14
+ __all__ = ["fields", "helper", "settings", "units", "widgets"]
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-pint
3
+ Version: 1.0.1
4
+ Summary: Quantity Field for Django using pint library for automated unit conversions
5
+ Author: Ben Harling
6
+ Author-email: Carli* Freudenberg <kound@posteo.de>
7
+ Maintainer-email: Carli* Freudenberg <kound@posteo.de>
8
+ License-Expression: MIT
9
+ Project-URL: Homepage, https://github.com/CarliJoy/django-pint/
10
+ Project-URL: Documentation, https://django-pint.readthedocs.io/en/latest/
11
+ Project-URL: Source Code, https://github.com/CarliJoy/django-pint/
12
+ Project-URL: Bug Tracker, https://github.com/CarliJoy/django-pint/issues
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Framework :: Django
22
+ Classifier: Framework :: Django :: 5.2
23
+ Classifier: Framework :: Django :: 6.0
24
+ Classifier: Intended Audience :: Developers
25
+ Classifier: Intended Audience :: Science/Research
26
+ Classifier: Operating System :: OS Independent
27
+ Classifier: Topic :: Scientific/Engineering
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Requires-Python: >=3.10
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE.txt
32
+ Requires-Dist: Django>=4.2
33
+ Requires-Dist: pint>=0.16
34
+ Dynamic: license-file
35
+
36
+
37
+ [![Build Status](https://api.travis-ci.com/CarliJoy/django-pint.svg?branch=master)](https://travis-ci.com/github/CarliJoy/django-pint)
38
+ [![codecov](https://codecov.io/gh/CarliJoy/django-pint/branch/master/graph/badge.svg?token=I3M4CLILXE)](https://codecov.io/gh/CarliJoy/django-pint)
39
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/django-pint.svg?maxAge=2592000?style=plastic)](https://pypistats.org/packages/django-pint)
40
+ [![Python Versions](https://img.shields.io/pypi/pyversions/django-pint.svg)](https://pypi.org/project/django-pint/)
41
+ [![PyPI Version](https://img.shields.io/pypi/v/django-pint.svg?maxAge=2592000?style=plastic)](https://pypi.org/project/django-pint/)
42
+ [![Project Status](https://img.shields.io/pypi/status/django-pint.svg)](https://pypi.org/project/SyncGitlab2MSProject/)
43
+ [![Wheel Build](https://img.shields.io/pypi/wheel/django-pint.svg)](https://pypi.org/project/django-pint/)
44
+ [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
45
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
46
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/CarliJoy/django-pint/main.svg)](https://results.pre-commit.ci/latest/github/CarliJoy/django-pint/main)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
48
+ [![Documentation Status](https://readthedocs.org/projects/django-pint/badge/?version=latest)](https://django-pint.readthedocs.io/en/latest/?badge=latest)
49
+
50
+ # Django Quantity Field
51
+
52
+
53
+ A Small django field extension allowing you to store quantities in certain units and perform conversions easily. Uses [pint](https://github.com/hgrecco/pint) behind the scenes. Also contains a form field class and form widget that allows a user to choose alternative units to input data. The cleaned_data will output the value in the base_units defined for the field, eg: you specify you want to store a value in grams but will allow users to input either grams or ounces.
54
+
55
+
56
+ ## Help wanted
57
+ I am currently not working with Django anymore. Therefore the Maintenance of this project is not a priority for me anymore.
58
+ If there is anybody that could imagine helping out maintaining the project, send me a mail.
59
+
60
+ ## Compatibility
61
+
62
+
63
+ Requires django >= 5.2, and python 3.10/3.11/3.12/3.13/3.14
64
+
65
+ Tested with the following combinations:
66
+ * Django 5.2 (Python 3.10, 3.11, 3.12, 3.13, 3.14)
67
+ * Django 6.0 (Python 3.12, 3.13, 3.14)
68
+
69
+ ## Installation
70
+
71
+
72
+ pip install django-pint
73
+
74
+
75
+ ## Simple Example
76
+
77
+ Best way to illustrate is with an example
78
+
79
+ # app/models.py
80
+
81
+ from django.db import models
82
+ from quantityfield.fields import QuantityField
83
+
84
+ class HayBale(models.Model):
85
+ weight = QuantityField('tonne')
86
+
87
+ Quantities are stored as float (Django FloatField) and retrieved like any other field
88
+
89
+ >> bale = HayBale.objects.create(weight=1.2)
90
+ >> bale = HayBale.objects.first()
91
+ >> bale.weight
92
+ <Quantity(1.2, 'tonne')>
93
+ >> bale.weight.magnitude
94
+ 1.2
95
+ >> bale.weight.units
96
+ 'tonne'
97
+ >> bale.weight.to('kilogram')
98
+ <Quantity(1200, 'kilogram')>
99
+ >> bale.weight.to('pound')
100
+ <Quantity(2645.55, 'pound')>
101
+
102
+ If your base unit is atomic (i.e. can be represented by an integer), you may also use `IntegerQuantityField` and `BigIntegerQuantityField`.
103
+
104
+ If you prefer exact units you can use the `DecimalQuantityField`
105
+
106
+ You can also pass Quantity objects to be stored in models. These are automatically converted to the units defined for the field
107
+ ( but can be converted to something else when retrieved of course ).
108
+
109
+ >> from quantityfield.units import ureg
110
+ >> Quantity = ureg.Quantity
111
+ >> pounds = Quantity(500 * ureg.pound)
112
+ >> bale = HayBale.objects.create(weight=pounds)
113
+ >> bale.weight
114
+ <Quantity(0.226796, 'tonne')>
115
+
116
+ Use the inbuilt form field and widget to allow input of quantity values in different units
117
+
118
+ from quantityfield.fields import QuantityFormField
119
+
120
+ class HayBaleForm(forms.Form):
121
+ weight = QuantityFormField(base_units='gram', unit_choices=['gram', 'ounce', 'milligram'])
122
+
123
+ The form will render a float input and a select widget to choose the units.
124
+ Whenever cleaned_data is presented from the above form the weight field value will be a
125
+ Quantity with the units set to grams (values are converted from the units input by the user).
126
+ You also can add the `unit_choices` directly to the `ModelField`. It will be propagated
127
+ correctly.
128
+
129
+ For comparative lookups, query values will be coerced into the correct units when comparing values,
130
+ this means that comparing 1 ounce to 1 tonne should yield the correct results.
131
+
132
+ less_than_a_tonne = HayBale.objects.filter(weight__lt=Quantity(2000 * ureg.pound))
133
+
134
+ You can also use a custom Pint unit registry in your project `settings.py`
135
+
136
+ # project/settings.py
137
+
138
+ from pint import UnitRegistry
139
+
140
+ # django-pint will set the DJANGO_PINT_UNIT_REGISTER automatically
141
+ # as application_registry
142
+ DJANGO_PINT_UNIT_REGISTER = UnitRegistry('your_units.txt')
143
+ DJANGO_PINT_UNIT_REGISTER.define('beer_bootle_weight = 0.8 * kg = beer')
144
+
145
+ # app/models.py
146
+
147
+ class HayBale(models.Model):
148
+ # now you can use your custom units in your models
149
+ custom_unit = QuantityField('beer')
150
+
151
+ Note: As the [documentation from pint](https://pint.readthedocs.io/en/latest/tutorial.html#using-pint-in-your-projects)
152
+ states quite clearly: For each project there should be only one unit registry.
153
+ Please note that if you change the unit registry for an already created project with
154
+ data in a database, you could invalidate your data! So be sure you know what you are
155
+ doing!
156
+ Still only adding units should be okay.
157
+
158
+ ## Development
159
+
160
+ ### Preparation
161
+
162
+ You need to install all Python Version that django-pint is compatible with.
163
+ In a *nix environment you best could use [pyenv](https://github.com/pyenv/pyenv) to do so.
164
+
165
+ Furthermore, you need to install [tox](https://tox.wiki/en/latest/) and [pre-commit](https://pre-commit.com/) to lint and test.
166
+
167
+ You also need docker as our tests require a postgres database to run.
168
+ We don't use SQL lite as some bugs only occurred using a proper database.
169
+
170
+ I recommend using [pipx](https://pypa.github.io/pipx/) to install them.
171
+
172
+ 1. Install `pipx` (see pipx documentation), i.e. with `python3 -m pip install --user pipx && python3 -m pipx ensurepath`
173
+ 2. Install `pre-commit` running `pipx install pre-commit`
174
+ 3. Install `tox` running `pipx install tox`
175
+ 4. Install the `tox-docker` plugin `pipx inject tox tox-docker`
176
+ 5. Fork `django-pint` and clone your fork (see [Tutorial](https://docs.github.com/get-started/quickstart/contributing-to-projects))
177
+ 6. Change into the repo `cd django-pint`
178
+ 7. Activate `pre-commit` for the repo running `pre-commit install`
179
+ 8. Check that all linter run fine with the cloned version by running `pre-commit run --all-files`
180
+ 9. Check that all tests succeed by running `tox`
181
+
182
+ **Congratulation** you successfully cloned and tested the upstream version of `django-pint`.
183
+
184
+ Now you can work on your feature branch and test your changes using `tox`.
185
+ Your code will be automatically linted and formatted by `pre-commit` if you commit your changes.
186
+ If it fails, simply add all changes and try again.
187
+ If this doesn't help look at the output of your `git commit` command.
188
+
189
+ Once you are done, [create a pull request](https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork).
190
+
191
+ ### Local development environment with Docker
192
+
193
+ To run a local development environment with Docker you need to run the following steps:
194
+ This is helpful if you have troubles installing `postgresql` or `psycopg2-binary`.
195
+
196
+ 1. `git clone` your fork
197
+ 2. run `cp .env.example .env`
198
+ 3. edit `.env` file and change it with your credentials ( the postgres host should match the service name in docker-file so you can use "postgres" )
199
+ 4. run `cp tests/local.py.docker-example tests/local.py`
200
+ 5. run `docker-compose up` in the root folder, this should build and start 2 containers, one for postgres and the other one python dependencies. Note you have to be in the [docker](https://stackoverflow.com/a/47078951/3813064) group for this to work.
201
+ 6. open a new terminal and run `docker-compose exec app bash`, this should open a ssh console in the docker container
202
+ 7. you can run `pytest` inside the container to see the result of the tests.
203
+
204
+ ### Updating the package
205
+ [Python](https://endoflife.date/python) and [Django](https://endoflife.date/django) major versions have defined EOL.
206
+ To reduce the maintenance burden and encourage users to use version still receiving security updates any `django-pint` update should match all and only these version of Python and Django that are supported.
207
+ Updating these dependencies have to be done in multiple places:
208
+ - `README.md`: Describing it to end users
209
+ - `tox.ini`: For local testing
210
+ - `pyproject.toml`: For usage with pip and displaying it in PyPi
211
+ - `.github/workflows/test.yaml`: For the CI/CD Definition
@@ -0,0 +1,12 @@
1
+ django_pint/__init__.py,sha256=rLa3Ej2DQbqPaygJUf258b7Cy5juDxvOwS8DHHwjaN8,466
2
+ django_pint-1.0.1.dist-info/licenses/LICENSE.txt,sha256=HMDhAOPCnlDTTF0u6fEEJz9rGCUQiS3lluNuFFvojAs,1154
3
+ quantityfield/__init__.py,sha256=W-x-EjdsFKJr29897UUG1XEBwS-91YxxgZCM8pbAc50,334
4
+ quantityfield/fields.py,sha256=62iV2Av_cpZLqSCXlSyp4RcGeRQSbOzlV0cZ_f4GlgE,15373
5
+ quantityfield/helper.py,sha256=6_7b8T-NKhSeoQQLyehl1NypS85UU8pGCxqUxLiM_wM,756
6
+ quantityfield/settings.py,sha256=XNbZdlOO_tfI1TtxR3Gc0JZSGMPGKVLn5Qob0utLzrQ,349
7
+ quantityfield/units.py,sha256=lIme1byRyf1lm2wfmlxLpq6Akq5pyQq3qBp4d7OfU2c,146
8
+ quantityfield/widgets.py,sha256=tNlszZEJ1Nq-gs9Fb4idp-SBZnW2uM6uobcfqCcK6uM,1180
9
+ django_pint-1.0.1.dist-info/METADATA,sha256=y2ktAr7w2FEqB7ec9t234gjuy9LVKCZDNbz2jjDTA_U,10576
10
+ django_pint-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ django_pint-1.0.1.dist-info/top_level.txt,sha256=oBxrgF8d06MHSvF2UQgGOvE4tOQfgG0n_eYvDPi8Jl4,26
12
+ django_pint-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (C) 2020-2022 django-pint authors (see AUTHORS file)
4
+
5
+ Copyright (c) 2020-2022 Carli* Freudenberg
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ django_pint
2
+ quantityfield
@@ -0,0 +1,10 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ # Change here if project is renamed and does not equal the package name
5
+ dist_name = "django-pint"
6
+ __version__ = version(dist_name)
7
+ except PackageNotFoundError: # pragma: no cover
8
+ __version__ = "unknown"
9
+ finally:
10
+ del version, PackageNotFoundError
@@ -0,0 +1,434 @@
1
+ import datetime
2
+ import typing
3
+ import warnings
4
+ from collections.abc import Callable, Sequence
5
+ from decimal import Decimal
6
+ from typing import Any, Union, cast
7
+
8
+ from django import forms
9
+ from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS
10
+ from django.core.exceptions import ValidationError
11
+ from django.db import models
12
+ from django.utils import formats
13
+ from django.utils.translation import gettext_lazy as _
14
+
15
+ from pint import Quantity
16
+
17
+ from .helper import check_matching_unit_dimension
18
+ from .units import ureg
19
+ from .widgets import QuantityWidget
20
+
21
+ DJANGO_JSON_SERIALIZABLE_BASE = Union[ # noqa: UP007
22
+ None, bool, str, int, float, complex, datetime.datetime
23
+ ]
24
+ DJANGO_JSON_SERIALIZABLE = Union[ # noqa: UP007
25
+ Sequence[DJANGO_JSON_SERIALIZABLE_BASE], dict[str, DJANGO_JSON_SERIALIZABLE_BASE]
26
+ ]
27
+ NUMBER_TYPE = Union[int, float, Decimal] # noqa: UP007
28
+
29
+
30
+ class QuantityFieldMixin:
31
+ to_number_type: Callable[[Any], NUMBER_TYPE]
32
+
33
+ # TODO: Move these stuff into an Protocol or anything
34
+ # better defining a Mixin
35
+ value_from_object: Callable[[Any], Any]
36
+ name: str
37
+ validate: Callable
38
+ run_validators: Callable
39
+
40
+ """A Django Model Field that resolves to a pint Quantity object"""
41
+
42
+ def __init__(
43
+ self,
44
+ base_units: str,
45
+ *args,
46
+ unit_choices: typing.Iterable[str] | None = None,
47
+ **kwargs,
48
+ ):
49
+ """
50
+ Create a Quantity field
51
+ :param base_units: Unit description of base unit
52
+ :param unit_choices: If given the possible unit choices with the same
53
+ dimension like the base_unit
54
+ """
55
+ if not isinstance(base_units, str):
56
+ raise ValueError(
57
+ 'QuantityField must be defined with base units, eg: "gram"'
58
+ )
59
+
60
+ self.ureg = ureg
61
+
62
+ # we do this as a way of raising an exception if some crazy unit was supplied.
63
+ unit = getattr(self.ureg, base_units) # noqa: F841
64
+
65
+ # if we've not hit an exception here, we should be all good
66
+ self.base_units = base_units
67
+
68
+ if unit_choices is None:
69
+ self.unit_choices: list[str] = [self.base_units]
70
+ else:
71
+ self.unit_choices = list(unit_choices)
72
+ # The multi widget expects that the base unit is always present as unit
73
+ # choice.
74
+ # Otherwise we would need to handle special cases for no good reason.
75
+ if self.base_units in self.unit_choices:
76
+ self.unit_choices.remove(self.base_units)
77
+ # Base unit has to be the first choice, always as all values are saved as
78
+ # base unit within the database and this would be the first unit shown
79
+ # in the widget
80
+ self.unit_choices = [self.base_units, *self.unit_choices]
81
+
82
+ # Check if all unit_choices are valid
83
+ check_matching_unit_dimension(self.ureg, self.base_units, self.unit_choices)
84
+
85
+ super().__init__(*args, **kwargs)
86
+
87
+ @property
88
+ def units(self) -> str:
89
+ return self.base_units
90
+
91
+ def deconstruct(
92
+ self,
93
+ ) -> tuple[
94
+ str,
95
+ str,
96
+ Sequence[DJANGO_JSON_SERIALIZABLE],
97
+ dict[str, DJANGO_JSON_SERIALIZABLE],
98
+ ]:
99
+ """
100
+ Return enough information to recreate the field as a 4-tuple:
101
+
102
+ * The name of the field on the model, if contribute_to_class() has
103
+ been run.
104
+ * The import path of the field, including the class:e.g.
105
+ django.db.models.IntegerField This should be the most portable
106
+ version, so less specific may be better.
107
+ * A list of positional arguments.
108
+ * A dict of keyword arguments.
109
+
110
+ """
111
+ super_deconstruct = getattr(super(), "deconstruct", None)
112
+ if not callable(super_deconstruct):
113
+ raise NotImplementedError(
114
+ "Tried to use Mixin on a class that has no deconstruct function. "
115
+ )
116
+ name, path, args, kwargs = super_deconstruct()
117
+ kwargs["base_units"] = self.base_units
118
+ kwargs["unit_choices"] = self.unit_choices
119
+ return name, path, args, kwargs
120
+
121
+ def fix_unit_registry(self, value: Quantity) -> Quantity:
122
+ """
123
+ Check if the UnitRegistry from settings is used.
124
+ If not try to fix it but give a warning.
125
+ """
126
+ if isinstance(value, Quantity):
127
+ if not isinstance(value, self.ureg.Quantity):
128
+ # Could be fatal if different unit registers are used but we assume
129
+ # the same is used within one project
130
+ # As we warn for this behaviour, we assume that the programmer
131
+ # will fix it and do not include more checks!
132
+ warnings.warn(
133
+ "Trying to set value from a different unit register for "
134
+ "quantityfield. "
135
+ "We assume the naming is equal but best use the same register as"
136
+ " for creating the quantityfield.",
137
+ RuntimeWarning,
138
+ stacklevel=2,
139
+ )
140
+ return value.magnitude * self.ureg(str(value.units))
141
+ else:
142
+ return value
143
+ else:
144
+ raise ValueError(f"Value '{value}' ({type(value)} is not a quantity.")
145
+
146
+ def get_prep_value(self, value: Any) -> NUMBER_TYPE | None:
147
+ """
148
+ Perform preliminary non-db specific value checks and conversions.
149
+
150
+ Make sure that we compare/use only values without a unit
151
+ """
152
+ # we store the value in the base units defined for this field
153
+ if value is None:
154
+ return None
155
+
156
+ if isinstance(value, Quantity):
157
+ quantity = self.fix_unit_registry(value)
158
+ magnitude = quantity.to(self.base_units).magnitude
159
+ else:
160
+ magnitude = value
161
+
162
+ try:
163
+ return self.to_number_type(magnitude)
164
+ except (TypeError, ValueError) as e:
165
+ raise e.__class__(
166
+ f"Field '{self.name}' expected a number but got {value!r}.",
167
+ ) from e
168
+
169
+ def get_db_prep_value(self, value, connection, prepared=False):
170
+ """
171
+ Convert value to database-compatible format.
172
+ This is called for both save() operations and filter lookups.
173
+ """
174
+ if prepared:
175
+ return value
176
+
177
+ # Use get_prep_value to convert Quantity to magnitude
178
+ return self.get_prep_value(value)
179
+
180
+ def value_to_string(self, obj) -> str:
181
+ value = self.value_from_object(obj)
182
+ return str(self.get_prep_value(value))
183
+
184
+ def from_db_value(self, value: Any, *args, **kwargs) -> Quantity | None:
185
+ if value is None:
186
+ return None
187
+ return self.ureg.Quantity(value, getattr(self.ureg, self.base_units))
188
+
189
+ def to_python(self, value) -> Quantity | None:
190
+ if isinstance(value, Quantity):
191
+ return self.fix_unit_registry(value)
192
+
193
+ if value is None:
194
+ return None
195
+
196
+ to_number = super().to_python
197
+ if not callable(to_number):
198
+ raise NotImplementedError(
199
+ "Mixin not used with a class that has to_python function"
200
+ )
201
+
202
+ value = cast(NUMBER_TYPE, to_number(value))
203
+
204
+ return self.ureg.Quantity(value, getattr(self.ureg, self.base_units))
205
+
206
+ def clean(self, value, model_instance) -> Quantity:
207
+ """
208
+ Convert the value's type and run validation. Validation errors
209
+ from to_python() and validate() are propagated. Return the correct
210
+ value if no error is raised.
211
+
212
+ This is a copy from djangos implementation but modified so that validators
213
+ are only checked against the magnitude as otherwise the default database
214
+ validators will not fail because of comparison errors
215
+ """
216
+ value = self.to_python(value)
217
+ check_value = self.get_prep_value(value)
218
+ self.validate(check_value, model_instance)
219
+ self.run_validators(check_value)
220
+ return value
221
+
222
+ def formfield(self, **kwargs):
223
+ defaults = {
224
+ "form_class": self.form_field_class,
225
+ "base_units": self.base_units,
226
+ "unit_choices": self.unit_choices,
227
+ }
228
+ defaults.update(kwargs)
229
+ return super().formfield(**defaults)
230
+
231
+
232
+ class QuantityFormFieldMixin:
233
+ """This formfield allows a user to choose which units they
234
+ wish to use to enter a value, but the value is yielded in
235
+ the base_units
236
+ """
237
+
238
+ to_number_type: Callable[[Any], NUMBER_TYPE]
239
+
240
+ # TODO: Move these stuff into an Protocol or anything
241
+ # better defining a Mixin
242
+ validate: Callable
243
+ run_validators: Callable
244
+ error_messages: dict[str, str]
245
+ empty_values: Sequence[Any]
246
+ localize: bool
247
+
248
+ def __init__(self, *args, **kwargs):
249
+ self.ureg = ureg
250
+ self.base_units = kwargs.pop("base_units", None)
251
+ if self.base_units is None:
252
+ raise ValueError(
253
+ "QuantityFormField requires a base_units kwarg of a "
254
+ "single unit type (eg: grams)"
255
+ )
256
+ self.units = kwargs.pop("unit_choices", [self.base_units])
257
+ if self.base_units not in self.units:
258
+ self.units.append(self.base_units)
259
+
260
+ check_matching_unit_dimension(self.ureg, self.base_units, self.units)
261
+
262
+ def is_special_admin_widget(widget) -> bool:
263
+ """
264
+ There are some special django admin widgets, defined
265
+ in django/contrib/admin/options.py in the variable
266
+ FORMFIELD_FOR_DBFIELD_DEFAULTS
267
+ The intention for Integer and BigIntegerField is only to
268
+ define the width.
269
+
270
+ They are set through a complicated process of the
271
+ modelform_factory setting formfield_callback to
272
+ ModelForm.formfield_to_dbfield
273
+
274
+ As they will overwrite our Widget we check for them and
275
+ will ignore them, if they are set as attribute.
276
+
277
+ We still will allow subclasses, so the end user has still
278
+ the possibility to use this widget.
279
+ """
280
+ WIDGETS_TO_IGNORE = [
281
+ FORMFIELD_FOR_DBFIELD_DEFAULTS[models.IntegerField],
282
+ FORMFIELD_FOR_DBFIELD_DEFAULTS[models.BigIntegerField],
283
+ ]
284
+ classes_to_ignore = [
285
+ ignored_widget["widget"].__name__
286
+ for ignored_widget in WIDGETS_TO_IGNORE
287
+ ]
288
+ return widget.__name__ in classes_to_ignore
289
+
290
+ widget = kwargs.get("widget")
291
+ if widget is None or is_special_admin_widget(widget):
292
+ widget = QuantityWidget(
293
+ base_units=self.base_units, allowed_types=self.units
294
+ )
295
+ kwargs["widget"] = widget
296
+ super().__init__(*args, **kwargs)
297
+
298
+ def prepare_value(self, value):
299
+ if isinstance(value, Quantity):
300
+ return value.to(self.base_units)
301
+ else:
302
+ return value
303
+
304
+ def clean(self, value):
305
+ """
306
+ General idea, first try to extract the correct number like done in the other
307
+ classes and then follow the same procedure as in the django default field
308
+ """
309
+ if isinstance(value, list) or isinstance(value, tuple):
310
+ val = value[0]
311
+ units = value[1]
312
+ else:
313
+ # If no multi widget is used
314
+ val = value
315
+ units = self.base_units
316
+
317
+ if val in self.empty_values:
318
+ # Make sure the correct functions are called also in case of empty values
319
+ self.validate(None)
320
+ self.run_validators(None)
321
+ return None
322
+
323
+ if units not in self.units:
324
+ raise ValidationError(_("%(units)s is not a valid choice") % locals())
325
+
326
+ if self.localize:
327
+ val = formats.sanitize_separators(value)
328
+
329
+ try:
330
+ val = self.to_number_type(val)
331
+ except (ValueError, TypeError):
332
+ raise ValidationError(
333
+ self.error_messages["invalid"], code="invalid"
334
+ ) from None
335
+
336
+ val = self.ureg.Quantity(val, getattr(self.ureg, units)).to(self.base_units)
337
+ self.validate(val.magnitude)
338
+ self.run_validators(val.magnitude)
339
+ return val
340
+
341
+
342
+ class QuantityFormField(QuantityFormFieldMixin, forms.FloatField):
343
+ to_number_type = float
344
+
345
+
346
+ class QuantityField(QuantityFieldMixin, models.FloatField):
347
+ form_field_class = QuantityFormField
348
+ to_number_type = float
349
+
350
+
351
+ class IntegerQuantityFormField(QuantityFormFieldMixin, forms.IntegerField):
352
+ to_number_type = int
353
+
354
+
355
+ class IntegerQuantityField(QuantityFieldMixin, models.IntegerField):
356
+ form_field_class = IntegerQuantityFormField
357
+ to_number_type = int
358
+
359
+
360
+ class BigIntegerQuantityField(QuantityFieldMixin, models.BigIntegerField):
361
+ form_field_class = IntegerQuantityFormField
362
+ to_number_type = int
363
+
364
+
365
+ class PositiveIntegerQuantityField(QuantityFieldMixin, models.PositiveIntegerField):
366
+ form_field_class = IntegerQuantityFormField
367
+ to_number_type = int
368
+
369
+
370
+ class DecimalQuantityFormField(QuantityFormFieldMixin, forms.DecimalField):
371
+ def to_number_type(self, x: object) -> Decimal:
372
+ return Decimal(str(x))
373
+
374
+
375
+ class DecimalQuantityField(QuantityFieldMixin, models.DecimalField):
376
+ form_field_class = DecimalQuantityFormField
377
+
378
+ def __init__(
379
+ self,
380
+ base_units: str,
381
+ *args,
382
+ unit_choices: list[str] | None = None,
383
+ verbose_name: str = None,
384
+ name: str = None,
385
+ max_digits: int = None,
386
+ decimal_places: int = None,
387
+ **kwargs,
388
+ ):
389
+ # We try to be friendly as default django, if there are missing argument
390
+ # we throw an error early
391
+ if not isinstance(max_digits, int) or not isinstance(decimal_places, int):
392
+ raise ValueError(
393
+ _(
394
+ "Invalid initialization for DecimalQuantityField! "
395
+ "We expect max_digits and decimal_places to be set as integers."
396
+ )
397
+ )
398
+ # and we also check the values to be sane
399
+ if decimal_places < 0 or max_digits < 1 or decimal_places > max_digits:
400
+ raise ValueError(
401
+ _(
402
+ "Invalid initialization for DecimalQuantityField! "
403
+ "max_digits and decimal_places need to positive and max_digits"
404
+ "needs to be larger than decimal_places and at least 1. "
405
+ "So max_digits=%(max_digits)s and "
406
+ "decimal_plactes=%(decimal_places)s "
407
+ "are not valid parameters."
408
+ )
409
+ % locals()
410
+ )
411
+
412
+ super().__init__(
413
+ base_units,
414
+ *args,
415
+ unit_choices=unit_choices,
416
+ verbose_name=verbose_name,
417
+ name=name,
418
+ max_digits=max_digits,
419
+ decimal_places=decimal_places,
420
+ **kwargs,
421
+ )
422
+
423
+ def to_number_type(self, x: object) -> Decimal:
424
+ return Decimal(str(x))
425
+
426
+ def get_db_prep_save(self, value, connection) -> Decimal:
427
+ """
428
+ Get Value that shall be saved to database, make sure it is transformed
429
+ """
430
+ converted = self.to_python(value)
431
+ magnitude = self.get_prep_value(converted)
432
+ return connection.ops.adapt_decimalfield_value(
433
+ magnitude, self.max_digits, self.decimal_places
434
+ )
@@ -0,0 +1,22 @@
1
+ from pint import DimensionalityError, UnitRegistry
2
+
3
+
4
+ def check_matching_unit_dimension(
5
+ ureg: UnitRegistry, base_units: str, units_to_check: list[str]
6
+ ) -> None:
7
+ """
8
+ Check if all units_to_check have the same Dimension like the base_units
9
+ If not
10
+ :raise DimensionalityError
11
+ """
12
+ base_unit = getattr(ureg, base_units)
13
+ # create a pint quantity by multiplying unit with magnitude of 1
14
+ base_quant = 1 * base_unit
15
+
16
+ for unit_string in units_to_check:
17
+ unit = getattr(ureg, unit_string)
18
+ # try to convert base qunatity to new unit, this also work for ureg.context
19
+ try:
20
+ base_quant.to(unit)
21
+ except DimensionalityError as e:
22
+ raise DimensionalityError(base_unit, unit) from e
@@ -0,0 +1,11 @@
1
+ __version__ = "0.4"
2
+ from django.conf import settings
3
+
4
+ from pint import UnitRegistry, set_application_registry
5
+
6
+ # Define default unit register
7
+ DJANGO_PINT_UNIT_REGISTER = getattr(
8
+ settings, "DJANGO_PINT_UNIT_REGISTER", UnitRegistry()
9
+ )
10
+ # Set as default application registry for i.e. for pickle
11
+ set_application_registry(DJANGO_PINT_UNIT_REGISTER)
quantityfield/units.py ADDED
@@ -0,0 +1,4 @@
1
+ from .settings import DJANGO_PINT_UNIT_REGISTER
2
+
3
+ # The unit register that was defined in the settings (shortcut)
4
+ ureg = DJANGO_PINT_UNIT_REGISTER
@@ -0,0 +1,36 @@
1
+ from numbers import Number
2
+
3
+ from django.forms.widgets import MultiWidget, NumberInput, Select
4
+
5
+ import pint
6
+
7
+ from .units import ureg
8
+
9
+
10
+ class QuantityWidget(MultiWidget):
11
+ def __init__(self, *, attrs=None, base_units=None, allowed_types=None):
12
+ self.ureg = ureg
13
+ choices = self.get_choices(allowed_types)
14
+ self.base_units = base_units
15
+ attrs = attrs or {}
16
+ attrs.setdefault("step", "any")
17
+ widgets = (NumberInput(attrs=attrs), Select(attrs=attrs, choices=choices))
18
+ super().__init__(widgets, attrs)
19
+
20
+ def get_choices(self, allowed_types=None):
21
+ allowed_types = allowed_types or dir(self.ureg)
22
+ return [(x, x) for x in allowed_types]
23
+
24
+ def decompress(self, value):
25
+ """This function is called during rendering
26
+
27
+ It is responsible to split values for the two widgets
28
+ """
29
+ if isinstance(value, Number):
30
+ # We assume that the given value is a proper number,
31
+ # ready to be rendered
32
+ return [value, self.base_units]
33
+ elif isinstance(value, pint.Quantity):
34
+ return [value.magnitude, value.units]
35
+
36
+ return [None, self.base_units]