pax_global_header00006660000000000000000000000064151352151010014504gustar00rootroot0000000000000052 comment=a1179f59d71b6b1ad1d9e9e2f59c191d8d1c4621 CarliJoy-django-pint-a1179f5/000077500000000000000000000000001513521510100157675ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/.coveragerc000066400000000000000000000011571513521510100201140ustar00rootroot00000000000000# .coveragerc to control coverage.py [run] branch = True source = django_pint # omit = bad_file.py [paths] source = src/ */site-packages/ [report] # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: def __repr__ if self\.debug # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError # Don't complain if non-runnable code isn't run: if 0: if __name__ == .__main__.: [html] show_contexts = True CarliJoy-django-pint-a1179f5/.env.example000066400000000000000000000001471513521510100202140ustar00rootroot00000000000000POSTGRES_HOST=postgres POSTGRES_PASSWORD=django_pint POSTGRES_USER=django_pint POSTGRES_DB=django_pint CarliJoy-django-pint-a1179f5/.github/000077500000000000000000000000001513521510100173275ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/.github/workflows/000077500000000000000000000000001513521510100213645ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/.github/workflows/publish-to-pypi.yml000066400000000000000000000054771513521510100251710ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries # also https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ # We use Trusted Publisher see https://docs.pypi.org/trusted-publishers/ name: Publish Python 🐍 distribution 📦 to PyPI on: push: tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.x" - name: Install pypa/build run: >- python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v6 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/django-pint permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v7 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github-release: name: >- Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release needs: - publish-to-pypi runs-on: ubuntu-latest permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases id-token: write # IMPORTANT: mandatory for sigstore steps: - name: Download all the dists uses: actions/download-artifact@v7 with: name: python-package-distributions path: dist/ - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v3.2.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} # Upload to GitHub Release using the `gh` CLI. # `dist/` contains the built packages, and the # sigstore-produced signatures and certificates. run: >- gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' CarliJoy-django-pint-a1179f5/.github/workflows/test.yaml000066400000000000000000000037401513521510100232330ustar00rootroot00000000000000name: test on: push: branches: ["main"] pull_request: branches: ["main"] concurrency: group: test-${{ github.head_ref }} cancel-in-progress: true env: PYTHONUNBUFFERED: "1" FORCE_COLOR: "1" POSTGRES_USER: "django_pint_gh_action" POSTGRES_PASSWORD: "django_pint_gh_action" POSTGRES_DB: "django" jobs: runner-job: name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] # Service containers to run with `container-job` services: # Label used to access the service container postgres: # Docker Hub image image: postgres:14-alpine # Set health checks to wait until postgres has started env: POSTGRES_USER: "django_pint_gh_action" POSTGRES_PASSWORD: "django_pint_gh_action" POSTGRES_DB: "django" options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: # Maps tcp port 5432 on service container to the host - 5432:5432 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup python for test ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install tox run: python -m pip install tox-gh>=1.2 - name: Setup test suite run: tox -vv --notest - name: Run test suite run: tox --skip-pkg-install env: # The hostname used to communicate with the PostgreSQL service container POSTGRES_HOST: localhost # The default PostgreSQL port POSTGRES_PORT: 5432 - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CarliJoy-django-pint-a1179f5/.gitignore000066400000000000000000000011131513521510100177530ustar00rootroot00000000000000# Temporary and binary files *~ *.py[cod] *.so *.pyc *.cfg !.isort.cfg !setup.cfg *.orig *.log *.pot __pycache__/* .cache/* .*.swp */.ipynb_checkpoints/* .DS_Store .env .python-version local.py # Project files .ropeproject .project .pydevproject .settings .idea tags # Package files *.egg *.eggs/ .installed.cfg *.egg-info # Unittest and coverage htmlcov/* .coverage .tox junit.xml coverage.xml .pytest_cache/ # Build and docs folder/files build/* dist/* sdist/* docs/README.rst docs/api/* docs/_rst/* docs/_build/* cover/* MANIFEST tests/local.py # Per-project virtualenvs .venv*/ CarliJoy-django-pint-a1179f5/.pre-commit-config.yaml000066400000000000000000000011631513521510100222510ustar00rootroot00000000000000exclude: '^docs/conf.py' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-added-large-files - id: check-ast - id: check-json - id: check-merge-conflict - id: check-xml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: requirements-txt-fixer - id: mixed-line-ending args: ['--fix=auto'] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.13 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/crate-ci/typos.git rev: 'v1' hooks: - id: typos CarliJoy-django-pint-a1179f5/.readthedocs.yml000066400000000000000000000003251513521510100210550ustar00rootroot00000000000000version: 2 build: os: ubuntu-24.04 tools: python: "3.12" sphinx: configuration: docs/conf.py fail_on_warning: false # we don't care atm python: install: - requirements: docs/requirements.txt CarliJoy-django-pint-a1179f5/AUTHORS.rst000066400000000000000000000007151513521510100176510ustar00rootroot00000000000000============ Contributors ============ * Ben Harling (Original Author and Maintainer 2016-2020) * Carli* Freudenberg * Robert Roskam * Colonel Vaideanu * Alex Bhandari * Jonas Haag * Igor Kozyrenko * Samuel Jennings * Adrian Orzechowski CarliJoy-django-pint-a1179f5/CHANGELOG.rst000066400000000000000000000075471513521510100200250ustar00rootroot00000000000000========= Changelog ========= Version 1.0.4 ============= - Fix support for expressions (subqueries) in bulk updates (`by @Adiorz, issue #119 `_) Version 1.0.3 ============= - Correct minimal Django version to 5.2 in pyproject.toml - Fix documentation builds Version 1.0.2 ============= - Fix broken pipeline for PyPI Sigstore uploads. No source code changes. Version 1.0.1 ============= - Fix Problem in Publish Pipeline using old upload-artifact (no source code changes) Version 1.0.0 ============= - Start following `SemVer `_ - Convert numeric types to str before calling Decimal `#101 by @mmarra `_ - Try unit conversion instead of literal dimensionality check `#108 by @SamuelJennings `_ - Drop support for Python 3.8 and 3.9 and Django 3.2 - Add support for Python 3.12, 3.13 and 3.14 and Django 6.0 `#116 by @Adiorz `_ - Modernize project setup: Use ``pyproject.toml`` only and ``ruff``. Version 0.7.2 ============= - fix conversion of number input to DecimalField (`issue #106 `_) Version 0.7.1 ============= - fix wrong unit display in widget (`issue #43 `_) Version 0.7.0 ============= - drop support for Django (<3.2) and Python Versions (<3.7) as they reached EOL - add ``PositiveIntegerQuantityField`` (`merge request #39 from jwygoda`_) - fix display of negative and scientific numbers in Widget (`merger request #41 from mikeford3`_) Version 0.6.3 ============= - fix error with Django 3.2 (`issue #36`_) - remove PrecisionError - restructure function a bit, add more type annotations Version 0.6.2 ============= - only a internal technical release as the PyPi token had to be removed due to security breach before and no new token was set before releasing 0.6.1 Version 0.6.1 ============= - Fix wrong mixin type for ``DecimalQuantityFormField`` (`merge request #31 from ikseek`_) - Fix ``BigIntegerQuantityField`` and ``IntegerQuantityField`` showing wrong widget in django admin `issue #34`_ Version 0.6 =========== - Added ``DecimalQuantityField`` - Improved Testing a lot, the different field types are tested individually. Now we have a total of 142 tests covering 98% of the code. 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 - use `black`_ to format code - using pytest instead of deprecated django-nose - Allow custom ureg and integer unit field (`merge request #11 from jonashaag`_) - pass base_unit from field to widget (`merge request #5 from cornelv`_) - now using PyScaffold for versioned release - added documentation and uploaded to readthedocs.org - using pre-commit (also in CI) - improved travis ci builds - Created Changelog file Version 0.4 =========== - Last release of Maintainer `Ben Harling`_ .. _Ben Harling: https://github.com/bharling .. _Carli* Freudenberg: https://github.com/CarliJoy .. _merge request #11 from jonashaag: https://github.com/CarliJoy/django-pint/pull/11 .. _merge request #5 from cornelv: https://github.com/CarliJoy/django-pint/pull/5 .. _merge request #31 from ikseek: https://github.com/CarliJoy/django-pint/pull/31 .. _issue #34: https://github.com/CarliJoy/django-pint/issues/34 .. _black: https://github.com/psf/black .. _issue #36: https://github.com/CarliJoy/django-pint/issues/36 .. _merge request #39 from jwygoda: https://github.com/CarliJoy/django-pint/pull/39 .. _merger request #41 from mikeford3: https://github.com/CarliJoy/django-pint/issues/40 CarliJoy-django-pint-a1179f5/Dockerfile000066400000000000000000000005761513521510100177710ustar00rootroot00000000000000FROM python:3.10-slim # install system dependencies RUN apt-get update RUN apt-get install -y build-essential libpq-dev curl gettext git postgresql-client RUN pip3 install --upgrade wheel setuptools pip RUN pip3 install pre-commit psycopg2-binary ipdb WORKDIR /django-pint # copy application files COPY . /django-pint RUN pre-commit install RUN pip install -e '.[testing]' CarliJoy-django-pint-a1179f5/LICENSE.txt000066400000000000000000000022021513521510100176060ustar00rootroot00000000000000The MIT License (MIT) Copyright (C) 2020-2022 django-pint authors (see AUTHORS file) Copyright (c) 2020-2022 Carli* Freudenberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. CarliJoy-django-pint-a1179f5/MANIFEST.in000066400000000000000000000000251513521510100175220ustar00rootroot00000000000000include *.md LICENSE CarliJoy-django-pint-a1179f5/Makefile000066400000000000000000000062401513521510100174310ustar00rootroot00000000000000# Makefile # Define a help target to show the available commands and their purposes .PHONY: help help: @echo "Available commands:" @echo " make dev-setup - Setup and test the development environment (requires pyenv and Docker already installed)" @echo " make install-pyenv - Install pyenv and its dependencies" @echo " make install-python - Install supported Python versions 3.8, 3.9, 3.10, 3.11 using pyenv" @echo " make install-docker - Install Docker and add the current user to the docker group" @echo " make install-pipx - Install pipx and ensure it's on your PATH" @echo " make install-pre-commit - Install pre-commit using pipx" @echo " make install-tox - Install tox using pipx and inject tox-docker" @echo " make activate-pre-commit - Activate pre-commit hooks" @echo " make lint - Run pre-commit checks on all files" @echo " make test - Run tests with tox" @echo " make dev-setup - Run all the above commands in sequence (pipx, pre-commit, tox, activate-pre-commit, lint, test)" @echo "" @echo "Suggested use:" @echo " If you already have pyenv and Docker installed, run 'make dev-setup' for the python/env you need setting up!" @echo " Otherwise, start with 'make install-pyenv', 'make install-python', and 'make install-docker'." @echo " When everything is in place run 'make test' to do run the testing suite." # Set the default target to help .PHONY: all all: help # Target to install pyenv .PHONY: install-pyenv install-pyenv: sudo apt-get install -y make build-essential libssl-dev zlib1g-dev sudo apt-get install -y libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev sudo apt-get install -y libncursesw5-dev xz-utils tk-dev libffi-dev liblzma-dev python3-openssl @echo "Installing pyenv..." /bin/bash -c "$$(curl -fsSL https://pyenv.run)" @echo "Pyenv has been installed. Please restart your shell or run 'exec $$SHELL' to refresh your environment." # Target to install all needed python versions .PHONY: install-python install-python: pyenv install 3.8 3.9 3.10 3.11 # Target for installing Docker .PHONY: install-docker install-docker: sudo apt update sudo apt install -y docker.io sudo usermod -aG docker $(USER) @echo "Please log out and log back in to apply the docker group changes." # Target for installing pipx and ensuring the path is set .PHONY: install-pipx install-pipx: python -m pip install --user --upgrade pip python -m pip install --user pipx python -m pipx ensurepath # Target for installing pre-commit using pipx .PHONY: install-pre-commit install-pre-commit: install-pipx pipx install pre-commit # Target for installing tox using pipx .PHONY: install-tox install-tox: install-pipx pipx install tox pipx inject tox tox-docker # Target to activate pre-commit hooks .PHONY: activate-pre-commit activate-pre-commit: pre-commit install # Target to run pre-commit checks on all files .PHONY: lint lint: pre-commit run --all-files # Target to run all the above commands in sequence .PHONY: dev-setup dev-setup: install-pipx install-pre-commit install-tox activate-pre-commit lint # Target to run tests with tox .PHONY: test test: tox CarliJoy-django-pint-a1179f5/README.md000066400000000000000000000215651513521510100172570ustar00rootroot00000000000000 [![Build Status](https://api.travis-ci.com/CarliJoy/django-pint.svg?branch=master)](https://travis-ci.com/github/CarliJoy/django-pint) [![codecov](https://codecov.io/gh/CarliJoy/django-pint/branch/master/graph/badge.svg?token=I3M4CLILXE)](https://codecov.io/gh/CarliJoy/django-pint) [![PyPI Downloads](https://img.shields.io/pypi/dm/django-pint.svg?maxAge=2592000?style=plastic)](https://pypistats.org/packages/django-pint) [![Python Versions](https://img.shields.io/pypi/pyversions/django-pint.svg)](https://pypi.org/project/django-pint/) [![PyPI Version](https://img.shields.io/pypi/v/django-pint.svg?maxAge=2592000?style=plastic)](https://pypi.org/project/django-pint/) [![Project Status](https://img.shields.io/pypi/status/django-pint.svg)](https://pypi.org/project/SyncGitlab2MSProject/) [![Wheel Build](https://img.shields.io/pypi/wheel/django-pint.svg)](https://pypi.org/project/django-pint/) [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) [![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) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Documentation Status](https://readthedocs.org/projects/django-pint/badge/?version=latest)](https://django-pint.readthedocs.io/en/latest/?badge=latest) # Django Quantity Field 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. ## Help wanted I am currently not working with Django anymore. Therefore the Maintenance of this project is not a priority for me anymore. If there is anybody that could imagine helping out maintaining the project, send me a mail. ## Compatibility Requires django >= 5.2, and python 3.10/3.11/3.12/3.13/3.14 Tested with the following combinations: * Django 5.2 (Python 3.10, 3.11, 3.12, 3.13, 3.14) * Django 6.0 (Python 3.12, 3.13, 3.14) ## Installation pip install django-pint ## Simple Example Best way to illustrate is with an example # app/models.py from django.db import models from quantityfield.fields import QuantityField class HayBale(models.Model): weight = QuantityField('tonne') Quantities are stored as float (Django FloatField) and retrieved like any other field >> bale = HayBale.objects.create(weight=1.2) >> bale = HayBale.objects.first() >> bale.weight >> bale.weight.magnitude 1.2 >> bale.weight.units 'tonne' >> bale.weight.to('kilogram') >> bale.weight.to('pound') If your base unit is atomic (i.e. can be represented by an integer), you may also use `IntegerQuantityField` and `BigIntegerQuantityField`. If you prefer exact units you can use the `DecimalQuantityField` 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.units import ureg >> Quantity = ureg.Quantity >> pounds = Quantity(500 * ureg.pound) >> bale = HayBale.objects.create(weight=pounds) >> bale.weight Use the inbuilt form field and widget to allow input of quantity values in different units from quantityfield.fields import QuantityFormField 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). 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. less_than_a_tonne = HayBale.objects.filter(weight__lt=Quantity(2000 * ureg.pound)) You can also use a custom Pint unit registry in your project `settings.py` # project/settings.py from pint import UnitRegistry # 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_bottle_weight = 0.8 * kg = beer') # app/models.py 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. ## Development ### Preparation You need to install all Python Version that django-pint is compatible with. In a *nix environment you best could use [pyenv](https://github.com/pyenv/pyenv) to do so. Furthermore, you need to install [tox](https://tox.wiki/en/latest/) and [pre-commit](https://pre-commit.com/) to lint and test. You also need docker as our tests require a postgres database to run. We don't use SQL lite as some bugs only occurred using a proper database. I recommend using [pipx](https://pypa.github.io/pipx/) to install them. 1. Install `pipx` (see pipx documentation), i.e. with `python3 -m pip install --user pipx && python3 -m pipx ensurepath` 2. Install `pre-commit` running `pipx install pre-commit` 3. Install `tox` running `pipx install tox` 4. Install the `tox-docker` plugin `pipx inject tox tox-docker` 5. Fork `django-pint` and clone your fork (see [Tutorial](https://docs.github.com/get-started/quickstart/contributing-to-projects)) 6. Change into the repo `cd django-pint` 7. Activate `pre-commit` for the repo running `pre-commit install` 8. Check that all linter run fine with the cloned version by running `pre-commit run --all-files` 9. Check that all tests succeed by running `tox` **Congratulation** you successfully cloned and tested the upstream version of `django-pint`. Now you can work on your feature branch and test your changes using `tox`. Your code will be automatically linted and formatted by `pre-commit` if you commit your changes. If it fails, simply add all changes and try again. If this doesn't help look at the output of your `git commit` command. 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). ### Local development environment with Docker To run a local development environment with Docker you need to run the following steps: This is helpful if you have troubles installing `postgresql` or `psycopg2-binary`. 1. `git clone` your fork 2. run `cp .env.example .env` 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" ) 4. run `cp tests/local.py.docker-example tests/local.py` 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. 6. open a new terminal and run `docker-compose exec app bash`, this should open a ssh console in the docker container 7. you can run `pytest` inside the container to see the result of the tests. ### Updating the package [Python](https://endoflife.date/python) and [Django](https://endoflife.date/django) major versions have defined EOL. 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. Updating these dependencies have to be done in multiple places: - `README.md`: Describing it to end users - `tox.ini`: For local testing - `pyproject.toml`: For usage with pip and displaying it in PyPi - `.github/workflows/test.yaml`: For the CI/CD Definition CarliJoy-django-pint-a1179f5/ci_setup_postgres.sh000077500000000000000000000014071513521510100220710ustar00rootroot00000000000000psql -c "create database django_pint;" -U postgres # Settings done according to tutorial https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-18-04 # Please not you might have to edit your pg_hba.conf in your local installation # see https://docs.boundlessgeo.com/suite/1.1.1/dataadmin/pgGettingStarted/firstconnect.html#allowing-local-connections psql -c "CREATE USER django_pint WITH PASSWORD 'not_secure_in_testing';" -U postgres psql -c "ALTER ROLE django_pint SET client_encoding TO 'utf8';" -U postgres psql -c "ALTER ROLE django_pint SET timezone TO 'UTC';" -U postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE django_pint TO django_pint;" -U postgres psql -c "ALTER ROLE django_pint CREATEDB;" -U postgres CarliJoy-django-pint-a1179f5/docker-compose.yaml000066400000000000000000000007331513521510100215700ustar00rootroot00000000000000version: '3' volumes: postgres_data: {} services: postgres: image: postgres:14-alpine volumes: - postgres_data:/var/lib/postgresql/data # DB persistence env_file: - .env ports: - "5432:5432" app: &app build: context: . dockerfile: Dockerfile image: django-pint depends_on: - postgres volumes: - .:/django-pint ports: - "8000:8000" env_file: - .env command: sleep 5d CarliJoy-django-pint-a1179f5/docs/000077500000000000000000000000001513521510100167175ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/docs/Makefile000066400000000000000000000022021513521510100203530ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build AUTODOCDIR = api # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1) $(error "The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/") endif .PHONY: help clean Makefile # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) clean: rm -rf $(BUILDDIR)/* $(AUTODOCDIR) # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) CarliJoy-django-pint-a1179f5/docs/_static/000077500000000000000000000000001513521510100203455ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/docs/_static/.gitignore000066400000000000000000000000221513521510100223270ustar00rootroot00000000000000# Empty directory CarliJoy-django-pint-a1179f5/docs/authors.rst000066400000000000000000000000511513521510100211320ustar00rootroot00000000000000.. _authors: .. include:: ../AUTHORS.rst CarliJoy-django-pint-a1179f5/docs/changelog.rst000066400000000000000000000000531513521510100213760ustar00rootroot00000000000000.. _changes: .. include:: ../CHANGELOG.rst CarliJoy-django-pint-a1179f5/docs/conf.py000066400000000000000000000251241513521510100202220ustar00rootroot00000000000000# # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import inspect import os import shutil import sys __location__ = os.path.join( os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe())) ) import m2r2 # Manually convert documentation readme_rst_content = m2r2.parse_from_file(os.path.join(__location__, "..", "README.md")) with open(os.path.join(__location__, "README.rst"), "w") as f: f.write(readme_rst_content) # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.join(__location__, "../src")) # -- Run sphinx-apidoc ------------------------------------------------------ # This hack is necessary since RTD does not issue `sphinx-apidoc` before running # `sphinx-build -b html . _build/html`. See Issue: # https://github.com/rtfd/readthedocs.org/issues/1139 # DON'T FORGET: Check the box "Install your project inside a virtualenv using # setup.py install" in the RTD Advanced Settings. # Additionally it helps us to avoid running apidoc manually try: # for Sphinx >= 1.7 from sphinx.ext import apidoc except ImportError: from sphinx import apidoc output_dir = os.path.join(__location__, "api") module_dir = os.path.join(__location__, "../src/quantityfield") shutil.rmtree(output_dir, ignore_errors=True) try: import sphinx from pkg_resources import parse_version cmd_line_template = "sphinx-apidoc -f -o {outputdir} {moduledir}" cmd_line = cmd_line_template.format(outputdir=output_dir, moduledir=module_dir) args = cmd_line.split(" ") if parse_version(sphinx.__version__) >= parse_version("1.7"): args = args[1:] apidoc.main(args) except Exception as e: print(f"Running `sphinx-apidoc` failed!\n{e}") # # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.todo", "sphinx.ext.autosummary", "sphinx.ext.viewcode", "sphinx.ext.coverage", "sphinx.ext.doctest", "sphinx.ext.ifconfig", "sphinx.ext.mathjax", "sphinx.ext.napoleon", "sphinx_rtd_theme", "recommonmark", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # Setup and Activate django so build are not failing import django import django.conf import pint django.conf.settings.configure( DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, SECRET_KEY="not very secret in tests", # noqa: S106 USE_I18N=True, USE_L10N=True, # Use common Middleware MIDDLEWARE=( "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ), INSTALLED_APPS=[ "django.contrib.auth", "django.contrib.admin", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.sites", "django.contrib.flatpages", "quantityfield", ], DJANGO_PINT_UNIT_REGISTER=pint.UnitRegistry(), ) django.setup() # To configure AutoStructify def setup(app): from recommonmark.transform import AutoStructify app.add_config_value( "recommonmark_config", { "auto_toc_tree_section": "Contents", "enable_eval_rst": True, "enable_math": True, "enable_inline_math": True, }, True, ) app.add_transform(AutoStructify) # The suffix of source filenames. source_suffix = [".rst", ".md"] # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "django-pint" copyright = "2020, Carli* Freudenberg" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = "" # Is set by calling `setup.py docs` # The full version, including alpha/beta/rc tags. release = "" # Is set by calling `setup.py docs` # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {"sidebar_width": "300px", "page_width": "1200px"} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". try: from django_pint import __version__ as version except ImportError: pass else: release = version # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = "" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "django_pint-doc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ( "index", "user_guide.tex", "django-pint Documentation", "Carli* Freudenberg", "manual", ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = "" # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- External mapping ------------------------------------------------------------ python_version = ".".join(map(str, sys.version_info[0:2])) intersphinx_mapping = { "sphinx": ("http://www.sphinx-doc.org/en/stable", None), "python": ("https://docs.python.org/" + python_version, None), "matplotlib": ("https://matplotlib.org", None), "numpy": ("https://docs.scipy.org/doc/numpy", None), "sklearn": ("http://scikit-learn.org/stable", None), "pandas": ("http://pandas.pydata.org/pandas-docs/stable", None), "scipy": ("https://docs.scipy.org/doc/scipy/reference", None), } CarliJoy-django-pint-a1179f5/docs/index.rst000066400000000000000000000024231513521510100205610ustar00rootroot00000000000000=========== django-pint =========== .. include:: README.rst Contents ======== .. toctree:: :maxdepth: 2 License Authors Changelog Module Reference Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` .. _toctree: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html .. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html .. _references: http://www.sphinx-doc.org/en/stable/markup/inline.html .. _Python domain syntax: http://sphinx-doc.org/domains.html#the-python-domain .. _Sphinx: http://www.sphinx-doc.org/ .. _Python: http://docs.python.org/ .. _Numpy: http://docs.scipy.org/doc/numpy .. _SciPy: http://docs.scipy.org/doc/scipy/reference/ .. _matplotlib: https://matplotlib.org/contents.html# .. _Pandas: http://pandas.pydata.org/pandas-docs/stable .. _Scikit-Learn: http://scikit-learn.org/stable .. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html .. _Google style: https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings .. _NumPy style: https://numpydoc.readthedocs.io/en/latest/format.html .. _classical style: http://www.sphinx-doc.org/en/stable/domains.html#info-field-lists CarliJoy-django-pint-a1179f5/docs/license.rst000066400000000000000000000001031513521510100210650ustar00rootroot00000000000000.. _license: ======= License ======= .. include:: ../LICENSE.txt CarliJoy-django-pint-a1179f5/docs/requirements.txt000066400000000000000000000001511513521510100222000ustar00rootroot00000000000000Django>=5.2 m2r2>=0.2.5 pint>=0.16 recommonmark>=0.6.0 six>=1.15.0 Sphinx>=3.3.1 sphinx-rtd-theme>=0.5.0 CarliJoy-django-pint-a1179f5/manage.py000066400000000000000000000001041513521510100175640ustar00rootroot00000000000000# This file seems to be required for pytest-django to work properly CarliJoy-django-pint-a1179f5/pyproject.toml000066400000000000000000000067361513521510100207170ustar00rootroot00000000000000[build-system] requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] build-backend = "setuptools.build_meta" [project] name = "django-pint" dynamic = ["version"] description = "Quantity Field for Django using pint library for automated unit conversions" authors = [ {name = "Carli* Freudenberg", email = "kound@posteo.de"}, {name = "Ben Harling"}, ] maintainers = [ {name = "Carli* Freudenberg", email = "kound@posteo.de"}, ] license = "MIT" license-files = ["LICENSE.txt"] readme = "README.md" requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "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", "Framework :: Django", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "Django>=5.2", "pint>=0.16", ] [project.urls] Homepage = "https://github.com/CarliJoy/django-pint/" Documentation = "https://django-pint.readthedocs.io/en/latest/" "Source Code" = "https://github.com/CarliJoy/django-pint/" "Bug Tracker" = "https://github.com/CarliJoy/django-pint/issues" [dependency-groups] testing = [ "pytest>=6.1", "pytest-cov>=2.1", "pytest-django>=0.4", "tox>=3.2", ] build_doc = [ "sphinx", "recommonmark>=0.6.0", "m2r2", ] [tool.ruff] line-length = 88 [tool.ruff.lint] exclude = [".tox", ".venv*", "build", "dist"] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "B", # flake8-bugbear "C4", # flake8-comprehensions "DJ", # flake8-django "UP", # pyupgrade "S", # flake8-bandit "T20", # flake8-print "SIM", # flake8-simplify ] ignore = [ "E501", # handled by format "SIM101", # multiple istinaces are harder to read IMHO ] [tool.ruff.lint.per-file-ignores] "tests/**.py" = [ "S101", # assert is wanted in tests "T20", # print is for tests "SIM117", # no need to combine with statements in tests "DJ008", # we don't care about __str__ in tests "DJ007", # we don't care for __all__ use in tests ] "docs/conf.py" = [ "T20", # print is for doc generation "E402", # late imports for autodoc generation is okay ] [tool.ruff.lint.isort] known-first-party = ["django_pint"] section-order = [ "future", "standard-library", "test", "django", "third-party", "pandas", "first-party", "local-folder", ] # Define your custom sections and their module globs. [tool.ruff.lint.isort.sections] test = ["pytest"] django = ["django"] pandas = ["pandas", "numpy"] [tool.setuptools] package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] exclude = ["tests"] [tool.setuptools_scm] # See configuration details in https://github.com/pypa/setuptools_scm version_scheme = "no-guess-dev" [tool.pytest.ini_options] addopts = [ "--cov=quantityfield", "--cov-report=xml", "--cov-report=term-missing", "--verbose", ] norecursedirs = [ "dist", "build", ".tox", ] testpaths = ["tests"] DJANGO_SETTINGS_MODULE = "tests.settings" CarliJoy-django-pint-a1179f5/src/000077500000000000000000000000001513521510100165565ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/src/django_pint/000077500000000000000000000000001513521510100210525ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/src/django_pint/__init__.py000066400000000000000000000007221513521510100231640ustar00rootroot00000000000000from importlib.metadata import PackageNotFoundError, version try: # Change here if project is renamed and does not equal the package name dist_name = "django-pint" __version__ = version(dist_name) except PackageNotFoundError: # pragma: no cover __version__ = "unknown" finally: del version, PackageNotFoundError from quantityfield import fields, helper, settings, units, widgets __all__ = ["fields", "helper", "settings", "units", "widgets"] CarliJoy-django-pint-a1179f5/src/quantityfield/000077500000000000000000000000001513521510100214405ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/src/quantityfield/__init__.py000066400000000000000000000005161513521510100235530ustar00rootroot00000000000000from importlib.metadata import PackageNotFoundError, version try: # Change here if project is renamed and does not equal the package name dist_name = "django-pint" __version__ = version(dist_name) except PackageNotFoundError: # pragma: no cover __version__ = "unknown" finally: del version, PackageNotFoundError CarliJoy-django-pint-a1179f5/src/quantityfield/fields.py000066400000000000000000000362151513521510100232670ustar00rootroot00000000000000import datetime import typing import warnings from collections.abc import Callable, Sequence from decimal import Decimal from typing import Any, Union, cast from django import forms from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS from django.core.exceptions import ValidationError from django.db import models from django.db.models.expressions import BaseExpression from django.utils import formats from django.utils.translation import gettext_lazy as _ from pint import Quantity from .helper import check_matching_unit_dimension from .units import ureg from .widgets import QuantityWidget DJANGO_JSON_SERIALIZABLE_BASE = Union[ # noqa: UP007 None, bool, str, int, float, complex, datetime.datetime ] DJANGO_JSON_SERIALIZABLE = Union[ # noqa: UP007 Sequence[DJANGO_JSON_SERIALIZABLE_BASE], dict[str, DJANGO_JSON_SERIALIZABLE_BASE] ] NUMBER_TYPE = Union[int, float, Decimal] # noqa: UP007 class QuantityFieldMixin: to_number_type: Callable[[Any], NUMBER_TYPE] # TODO: Move these stuff into an Protocol or anything # better defining a Mixin value_from_object: Callable[[Any], Any] name: str validate: Callable run_validators: Callable """A Django Model Field that resolves to a pint Quantity object""" def __init__( self, base_units: str, *args, unit_choices: typing.Iterable[str] | None = 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 = 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 = list(unit_choices) # The multi widget expects that the base unit is always present as unit # choice. # Otherwise we would need to handle special cases for no good reason. if self.base_units in self.unit_choices: self.unit_choices.remove(self.base_units) # Base unit has to be the first choice, always as all values are saved as # base unit within the database and this would be the first unit shown # in the widget self.unit_choices = [self.base_units, *self.unit_choices] # Check if all unit_choices are valid check_matching_unit_dimension(self.ureg, self.base_units, self.unit_choices) super().__init__(*args, **kwargs) @property def units(self) -> str: return self.base_units def deconstruct( self, ) -> tuple[ str, str, Sequence[DJANGO_JSON_SERIALIZABLE], dict[str, DJANGO_JSON_SERIALIZABLE], ]: """ Return enough information to recreate the field as a 4-tuple: * The name of the field on the model, if contribute_to_class() has been run. * The import path of the field, including the class:e.g. django.db.models.IntegerField This should be the most portable version, so less specific may be better. * A list of positional arguments. * A dict of keyword arguments. """ super_deconstruct = getattr(super(), "deconstruct", None) if not callable(super_deconstruct): raise NotImplementedError( "Tried to use Mixin on a class that has no deconstruct function. " ) name, path, args, kwargs = super_deconstruct() kwargs["base_units"] = self.base_units kwargs["unit_choices"] = self.unit_choices return name, path, args, kwargs def fix_unit_registry(self, value: Quantity) -> Quantity: """ Check if the UnitRegistry from settings is used. If not try to fix it but give a warning. """ if isinstance(value, Quantity): if not isinstance(value, self.ureg.Quantity): # Could be fatal if different unit registers are used but we assume # the same is used within one project # As we warn for this behaviour, we assume that the programmer # will fix it and do not include more checks! warnings.warn( "Trying to set value from a different unit register for " "quantityfield. " "We assume the naming is equal but best use the same register as" " for creating the quantityfield.", RuntimeWarning, stacklevel=2, ) return value.magnitude * self.ureg(str(value.units)) else: return value else: raise ValueError(f"Value '{value}' ({type(value)} is not a quantity.") def get_prep_value(self, value: Any) -> NUMBER_TYPE | None: """ Perform preliminary non-db specific value checks and conversions. Make sure that we compare/use only values without a unit """ # we store the value in the base units defined for this field if value is None: return None if isinstance(value, Quantity): quantity = self.fix_unit_registry(value) magnitude = quantity.to(self.base_units).magnitude else: magnitude = value try: return self.to_number_type(magnitude) except (TypeError, ValueError) as e: raise e.__class__( f"Field '{self.name}' expected a number but got {value!r}.", ) from e def get_db_prep_value(self, value, connection, prepared=False): """ Convert value to database-compatible format. This is called for both save() operations and filter lookups. """ if prepared: return value # Use get_prep_value to convert Quantity to magnitude return self.get_prep_value(value) def value_to_string(self, obj) -> str: value = self.value_from_object(obj) return str(self.get_prep_value(value)) def from_db_value(self, value: Any, *args, **kwargs) -> Quantity | None: if value is None: return None return self.ureg.Quantity(value, getattr(self.ureg, self.base_units)) def to_python(self, value) -> Quantity | None: if isinstance(value, Quantity): return self.fix_unit_registry(value) if value is None: return None to_number = super().to_python if not callable(to_number): raise NotImplementedError( "Mixin not used with a class that has to_python function" ) value = cast(NUMBER_TYPE, to_number(value)) return self.ureg.Quantity(value, getattr(self.ureg, self.base_units)) def clean(self, value, model_instance) -> Quantity: """ Convert the value's type and run validation. Validation errors from to_python() and validate() are propagated. Return the correct value if no error is raised. This is a copy from djangos implementation but modified so that validators are only checked against the magnitude as otherwise the default database validators will not fail because of comparison errors """ value = self.to_python(value) check_value = self.get_prep_value(value) self.validate(check_value, model_instance) self.run_validators(check_value) return value def formfield(self, **kwargs): defaults = { "form_class": self.form_field_class, "base_units": self.base_units, "unit_choices": self.unit_choices, } defaults.update(kwargs) return super().formfield(**defaults) class QuantityFormFieldMixin: """This formfield allows a user to choose which units they wish to use to enter a value, but the value is yielded in the base_units """ to_number_type: Callable[[Any], NUMBER_TYPE] # TODO: Move these stuff into an Protocol or anything # better defining a Mixin validate: Callable run_validators: Callable error_messages: dict[str, str] empty_values: Sequence[Any] localize: bool def __init__(self, *args, **kwargs): self.ureg = ureg self.base_units = kwargs.pop("base_units", None) if self.base_units is None: raise ValueError( "QuantityFormField requires a base_units kwarg of a " "single unit type (eg: grams)" ) self.units = kwargs.pop("unit_choices", [self.base_units]) if self.base_units not in self.units: self.units.append(self.base_units) check_matching_unit_dimension(self.ureg, self.base_units, self.units) def is_special_admin_widget(widget) -> bool: """ There are some special django admin widgets, defined in django/contrib/admin/options.py in the variable FORMFIELD_FOR_DBFIELD_DEFAULTS The intention for Integer and BigIntegerField is only to define the width. They are set through a complicated process of the modelform_factory setting formfield_callback to ModelForm.formfield_to_dbfield As they will overwrite our Widget we check for them and will ignore them, if they are set as attribute. We still will allow subclasses, so the end user has still the possibility to use this widget. """ WIDGETS_TO_IGNORE = [ FORMFIELD_FOR_DBFIELD_DEFAULTS[models.IntegerField], FORMFIELD_FOR_DBFIELD_DEFAULTS[models.BigIntegerField], ] classes_to_ignore = [ ignored_widget["widget"].__name__ for ignored_widget in WIDGETS_TO_IGNORE ] return widget.__name__ in classes_to_ignore widget = kwargs.get("widget") if widget is None or is_special_admin_widget(widget): widget = QuantityWidget( base_units=self.base_units, allowed_types=self.units ) kwargs["widget"] = widget super().__init__(*args, **kwargs) def prepare_value(self, value): if isinstance(value, Quantity): return value.to(self.base_units) else: return value def clean(self, value): """ General idea, first try to extract the correct number like done in the other classes and then follow the same procedure as in the django default field """ if isinstance(value, list) or isinstance(value, tuple): val = value[0] units = value[1] else: # If no multi widget is used val = value units = self.base_units if val in self.empty_values: # Make sure the correct functions are called also in case of empty values self.validate(None) self.run_validators(None) return None if units not in self.units: raise ValidationError(_("%(units)s is not a valid choice") % locals()) if self.localize: val = formats.sanitize_separators(value) try: val = self.to_number_type(val) except (ValueError, TypeError): raise ValidationError( self.error_messages["invalid"], code="invalid" ) from None val = self.ureg.Quantity(val, getattr(self.ureg, units)).to(self.base_units) self.validate(val.magnitude) self.run_validators(val.magnitude) return val class QuantityFormField(QuantityFormFieldMixin, forms.FloatField): to_number_type = float class QuantityField(QuantityFieldMixin, models.FloatField): form_field_class = QuantityFormField to_number_type = float class IntegerQuantityFormField(QuantityFormFieldMixin, forms.IntegerField): to_number_type = int class IntegerQuantityField(QuantityFieldMixin, models.IntegerField): form_field_class = IntegerQuantityFormField to_number_type = int class BigIntegerQuantityField(QuantityFieldMixin, models.BigIntegerField): form_field_class = IntegerQuantityFormField to_number_type = int class PositiveIntegerQuantityField(QuantityFieldMixin, models.PositiveIntegerField): form_field_class = IntegerQuantityFormField to_number_type = int class DecimalQuantityFormField(QuantityFormFieldMixin, forms.DecimalField): def to_number_type(self, x: object) -> Decimal: return Decimal(str(x)) class DecimalQuantityField(QuantityFieldMixin, models.DecimalField): form_field_class = DecimalQuantityFormField def __init__( self, base_units: str, *args, unit_choices: list[str] | None = None, verbose_name: str = None, name: str = None, max_digits: int = None, decimal_places: int = None, **kwargs, ): # We try to be friendly as default django, if there are missing argument # we throw an error early if not isinstance(max_digits, int) or not isinstance(decimal_places, int): raise ValueError( _( "Invalid initialization for DecimalQuantityField! " "We expect max_digits and decimal_places to be set as integers." ) ) # and we also check the values to be sane if decimal_places < 0 or max_digits < 1 or decimal_places > max_digits: raise ValueError( _( "Invalid initialization for DecimalQuantityField! " "max_digits and decimal_places need to positive and max_digits" "needs to be larger than decimal_places and at least 1. " "So max_digits=%(max_digits)s and " "decimal_plactes=%(decimal_places)s " "are not valid parameters." ) % locals() ) super().__init__( base_units, *args, unit_choices=unit_choices, verbose_name=verbose_name, name=name, max_digits=max_digits, decimal_places=decimal_places, **kwargs, ) def to_number_type(self, x: object) -> Decimal: return Decimal(str(x)) def get_db_prep_save(self, value, connection) -> Decimal: """ Get Value that shall be saved to database, make sure it is transformed """ if isinstance(value, BaseExpression): return value converted = self.to_python(value) magnitude = self.get_prep_value(converted) return connection.ops.adapt_decimalfield_value( magnitude, self.max_digits, self.decimal_places ) CarliJoy-django-pint-a1179f5/src/quantityfield/helper.py000066400000000000000000000013641513521510100232750ustar00rootroot00000000000000from pint import DimensionalityError, UnitRegistry 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) # create a pint quantity by multiplying unit with magnitude of 1 base_quant = 1 * base_unit for unit_string in units_to_check: unit = getattr(ureg, unit_string) # try to convert base qunatity to new unit, this also work for ureg.context try: base_quant.to(unit) except DimensionalityError as e: raise DimensionalityError(base_unit, unit) from e CarliJoy-django-pint-a1179f5/src/quantityfield/settings.py000066400000000000000000000005351513521510100236550ustar00rootroot00000000000000__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) CarliJoy-django-pint-a1179f5/src/quantityfield/units.py000066400000000000000000000002221513521510100231500ustar00rootroot00000000000000from .settings import DJANGO_PINT_UNIT_REGISTER # The unit register that was defined in the settings (shortcut) ureg = DJANGO_PINT_UNIT_REGISTER CarliJoy-django-pint-a1179f5/src/quantityfield/widgets.py000066400000000000000000000022341513521510100234610ustar00rootroot00000000000000from numbers import Number from django.forms.widgets import MultiWidget, NumberInput, Select import pint from .units import ureg class QuantityWidget(MultiWidget): 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 {} attrs.setdefault("step", "any") widgets = (NumberInput(attrs=attrs), Select(attrs=attrs, choices=choices)) super().__init__(widgets, attrs) def get_choices(self, allowed_types=None): allowed_types = allowed_types or dir(self.ureg) return [(x, x) for x in allowed_types] def decompress(self, value): """This function is called during rendering It is responsible to split values for the two widgets """ if isinstance(value, Number): # We assume that the given value is a proper number, # ready to be rendered return [value, self.base_units] elif isinstance(value, pint.Quantity): return [value.magnitude, value.units] return [None, self.base_units] CarliJoy-django-pint-a1179f5/tests/000077500000000000000000000000001513521510100171315ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/tests/__init__.py000066400000000000000000000000001513521510100212300ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/tests/conftest.py000066400000000000000000000002571513521510100213340ustar00rootroot00000000000000""" Dummy conftest.py for django_pint. If you don't know what this is for, just leave it empty. Read more about conftest.py under: https://pytest.org/latest/plugins.html """ CarliJoy-django-pint-a1179f5/tests/dummyapp/000077500000000000000000000000001513521510100207655ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/tests/dummyapp/__init__.py000066400000000000000000000000001513521510100230640ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/tests/dummyapp/admin.py000066400000000000000000000022121513521510100224240ustar00rootroot00000000000000from django.contrib import admin from . import models class ReadOnlyEditing(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): if obj is not None: return list(self.get_fields(request)) return [] admin.site.register(models.BigIntFieldSaveModel, ReadOnlyEditing) admin.site.register(models.ChoicesDefinedInModel, ReadOnlyEditing) admin.site.register(models.ChoicesDefinedInModelInt, ReadOnlyEditing) admin.site.register(models.CustomUregDecimalHayBale, ReadOnlyEditing) admin.site.register(models.CustomUregHayBale, ReadOnlyEditing) admin.site.register(models.DecimalFieldSaveModel, ReadOnlyEditing) admin.site.register(models.EmptyHayBaleBigInt, ReadOnlyEditing) admin.site.register(models.EmptyHayBaleDecimal, ReadOnlyEditing) admin.site.register(models.EmptyHayBaleFloat, ReadOnlyEditing) admin.site.register(models.EmptyHayBaleInt, ReadOnlyEditing) admin.site.register(models.FloatFieldSaveModel, ReadOnlyEditing) admin.site.register(models.HayBale, ReadOnlyEditing) admin.site.register(models.IntFieldSaveModel, ReadOnlyEditing) admin.site.register(models.OffsetUnitFloatFieldSaveModel, ReadOnlyEditing) CarliJoy-django-pint-a1179f5/tests/dummyapp/asgi.py000066400000000000000000000006031513521510100222610ustar00rootroot00000000000000""" ASGI config for testproject project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ """ import os from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") application = get_asgi_application() CarliJoy-django-pint-a1179f5/tests/dummyapp/forms.py000066400000000000000000000053621513521510100224730ustar00rootroot00000000000000from django import forms from quantityfield.fields import ( DecimalQuantityFormField, IntegerQuantityFormField, QuantityFormField, ) from tests.dummyapp.models import ( BigIntFieldSaveModel, DecimalFieldSaveModel, FloatFieldSaveModel, IntFieldSaveModel, ) class DefaultFormFloat(forms.ModelForm): weight = QuantityFormField(base_units="gram", unit_choices=["ounce", "gram"]) class Meta: model = FloatFieldSaveModel fields = "__all__" class DefaultFormInt(forms.ModelForm): weight = IntegerQuantityFormField(base_units="gram", unit_choices=["ounce", "gram"]) class Meta: model = IntFieldSaveModel fields = "__all__" class DefaultFormBigInt(forms.ModelForm): weight = IntegerQuantityFormField(base_units="gram", unit_choices=["ounce", "gram"]) class Meta: model = BigIntFieldSaveModel fields = "__all__" class DefaultFormDecimal(forms.ModelForm): weight = DecimalQuantityFormField(base_units="gram", unit_choices=["ounce", "gram"]) class Meta: model = DecimalFieldSaveModel fields = "__all__" class DefaultFormFieldsFloat(forms.ModelForm): weight = forms.FloatField() class Meta: model = FloatFieldSaveModel fields = "__all__" class DefaultFormFieldsDecimal(forms.ModelForm): weight = forms.FloatField() class Meta: model = DecimalFieldSaveModel fields = "__all__" class DefaultFormFieldsInt(forms.ModelForm): weight = forms.IntegerField() class Meta: model = IntFieldSaveModel fields = "__all__" class DefaultFormFieldsBigInt(forms.ModelForm): weight = forms.IntegerField() class Meta: model = BigIntFieldSaveModel fields = "__all__" class DefaultWidgetsFormFloat(forms.ModelForm): weight = QuantityFormField( base_units="gram", unit_choices=["ounce", "gram"], widget=forms.NumberInput ) class Meta: model = FloatFieldSaveModel fields = "__all__" class DefaultWidgetsFormDecimal(forms.ModelForm): weight = QuantityFormField( base_units="gram", unit_choices=["ounce", "gram"], widget=forms.NumberInput ) class Meta: model = DecimalFieldSaveModel fields = "__all__" class DefaultWidgetsFormInt(forms.ModelForm): weight = IntegerQuantityFormField( base_units="gram", unit_choices=["ounce", "gram"], widget=forms.NumberInput ) class Meta: model = IntFieldSaveModel fields = "__all__" class DefaultWidgetsFormBigInt(forms.ModelForm): weight = IntegerQuantityFormField( base_units="gram", unit_choices=["ounce", "gram"], widget=forms.NumberInput ) class Meta: model = BigIntFieldSaveModel fields = "__all__" CarliJoy-django-pint-a1179f5/tests/dummyapp/helper.py000066400000000000000000000016511513521510100226210ustar00rootroot00000000000000from django.db.models.base import ModelBase from quantityfield.fields import QuantityFieldMixin from .models import * # noqa: F401, F403 def get_test_models() -> dict[str, ModelBase]: """ Get a list of all Test models """ result = {} for name, obj in globals().items(): if ( not name.startswith("_") and isinstance(obj, ModelBase) and not obj._meta.abstract and obj._meta.app_config.name.endswith("dummyapp") ): result[name] = obj return result def print_admins(): for model in sorted(get_test_models().keys()): print(f"admin.site.register({model}, ReadOnlyEditing)") def print_test_admin_choices(): for model_name, model in get_test_models().items(): for field in model._meta.fields: if isinstance(field, QuantityFieldMixin): print(f"(models.{model_name}, '{field.name}'),") CarliJoy-django-pint-a1179f5/tests/dummyapp/migrations/000077500000000000000000000000001513521510100231415ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/tests/dummyapp/migrations/0001_initial.py000066400000000000000000000263551513521510100256170ustar00rootroot00000000000000# Generated by Django 3.2.9 on 2022-04-24 09:40 from django.db import migrations, models import quantityfield.fields class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="BigIntFieldSaveModel", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.BigIntegerQuantityField( base_units="gram", unit_choices=["gram"] ), ), ], options={ "abstract": False, }, ), migrations.CreateModel( name="ChoicesDefinedInModel", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "weight", quantityfield.fields.QuantityField( base_units="kilogram", unit_choices=["milligram", "pounds"] ), ), ], ), migrations.CreateModel( name="ChoicesDefinedInModelInt", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "weight", quantityfield.fields.IntegerQuantityField( base_units="kilogram", unit_choices=["milligram", "pounds"] ), ), ], ), migrations.CreateModel( name="CustomUregDecimalHayBale", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "custom_decimal", quantityfield.fields.DecimalQuantityField( base_units="custom", decimal_places=2, max_digits=10, unit_choices=["custom"], ), ), ], ), migrations.CreateModel( name="CustomUregHayBale", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "custom", quantityfield.fields.QuantityField( base_units="custom", unit_choices=["custom"] ), ), ( "custom_int", quantityfield.fields.IntegerQuantityField( base_units="custom", unit_choices=["custom"] ), ), ( "custom_bigint", quantityfield.fields.BigIntegerQuantityField( base_units="custom", unit_choices=["custom"] ), ), ], ), migrations.CreateModel( name="DecimalFieldSaveModel", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.DecimalQuantityField( base_units="gram", decimal_places=2, max_digits=10, unit_choices=["gram"], ), ), ], options={ "abstract": False, }, ), migrations.CreateModel( name="EmptyHayBaleBigInt", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.BigIntegerQuantityField( base_units="gram", null=True, unit_choices=["gram"] ), ), ], ), migrations.CreateModel( name="EmptyHayBaleDecimal", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.DecimalQuantityField( base_units="gram", decimal_places=2, max_digits=10, null=True, unit_choices=["gram"], ), ), ( "compare", models.DecimalField(decimal_places=2, max_digits=10, null=True), ), ], ), migrations.CreateModel( name="EmptyHayBaleFloat", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.QuantityField( base_units="gram", null=True, unit_choices=["gram"] ), ), ], ), migrations.CreateModel( name="EmptyHayBaleInt", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.IntegerQuantityField( base_units="gram", null=True, unit_choices=["gram"] ), ), ], ), migrations.CreateModel( name="EmptyHayBalePositiveInt", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.PositiveIntegerQuantityField( base_units="gram", null=True, unit_choices=["gram"] ), ), ], ), migrations.CreateModel( name="FloatFieldSaveModel", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.QuantityField( base_units="gram", unit_choices=["gram"] ), ), ], options={ "abstract": False, }, ), migrations.CreateModel( name="HayBale", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.QuantityField( base_units="gram", unit_choices=["gram"] ), ), ( "weight_int", quantityfield.fields.IntegerQuantityField( base_units="gram", blank=True, null=True, unit_choices=["gram"] ), ), ( "weight_bigint", quantityfield.fields.BigIntegerQuantityField( base_units="gram", blank=True, null=True, unit_choices=["gram"] ), ), ], ), migrations.CreateModel( name="IntFieldSaveModel", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.IntegerQuantityField( base_units="gram", unit_choices=["gram"] ), ), ], options={ "abstract": False, }, ), ] CarliJoy-django-pint-a1179f5/tests/dummyapp/migrations/0002_offsetunitfloatfieldsavemodel.py000066400000000000000000000017721513521510100323030ustar00rootroot00000000000000# Generated by Django 4.2.5 on 2023-09-25 08:24 from django.db import migrations, models import quantityfield.fields class Migration(migrations.Migration): dependencies = [ ("dummyapp", "0001_initial"), ] operations = [ migrations.CreateModel( name="OffsetUnitFloatFieldSaveModel", fields=[ ( "id", models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("name", models.CharField(max_length=20)), ( "weight", quantityfield.fields.QuantityField( base_units="degC", unit_choices=["degC"] ), ), ], options={ "abstract": False, }, ), ] CarliJoy-django-pint-a1179f5/tests/dummyapp/migrations/__init__.py000066400000000000000000000000001513521510100252400ustar00rootroot00000000000000CarliJoy-django-pint-a1179f5/tests/dummyapp/models.py000066400000000000000000000052121513521510100226220ustar00rootroot00000000000000from django.db import models from django.db.models import DecimalField import quantityfield.fields from quantityfield.fields import ( BigIntegerQuantityField, DecimalQuantityField, IntegerQuantityField, QuantityField, ) class FieldSaveModel(models.Model): name = models.CharField(max_length=20) weight = ... class Meta: abstract = True class FloatFieldSaveModel(FieldSaveModel): weight = QuantityField("gram") class IntFieldSaveModel(FieldSaveModel): weight = IntegerQuantityField("gram") class BigIntFieldSaveModel(FieldSaveModel): weight = BigIntegerQuantityField("gram") class DecimalFieldSaveModel(FieldSaveModel): weight = DecimalQuantityField("gram", max_digits=10, decimal_places=2) class HayBale(models.Model): name = models.CharField(max_length=20) weight = QuantityField("gram") weight_int = IntegerQuantityField("gram", blank=True, null=True) weight_bigint = BigIntegerQuantityField("gram", blank=True, null=True) class EmptyHayBaleFloat(models.Model): name = models.CharField(max_length=20) weight = QuantityField("gram", null=True) class EmptyHayBaleInt(models.Model): name = models.CharField(max_length=20) weight = IntegerQuantityField("gram", null=True) class EmptyHayBalePositiveInt(models.Model): name = models.CharField(max_length=20) weight = quantityfield.fields.PositiveIntegerQuantityField("gram", null=True) class EmptyHayBaleBigInt(models.Model): name = models.CharField(max_length=20) weight = BigIntegerQuantityField("gram", null=True) class EmptyHayBaleDecimal(models.Model): name = models.CharField(max_length=20) weight = DecimalQuantityField("gram", null=True, max_digits=10, decimal_places=2) # Value to compare with default implementation compare = DecimalField(max_digits=10, decimal_places=2, null=True) class CustomUregHayBale(models.Model): # Custom is defined in settings in conftest.py custom = QuantityField("custom") custom_int = IntegerQuantityField("custom") custom_bigint = BigIntegerQuantityField("custom") class CustomUregDecimalHayBale(models.Model): custom_decimal = DecimalQuantityField("custom", max_digits=10, decimal_places=2) class ChoicesDefinedInModel(models.Model): weight = QuantityField("kilogram", unit_choices=["milligram", "pounds"]) class ChoicesDefinedInModelInt(models.Model): weight = IntegerQuantityField("kilogram", unit_choices=["milligram", "pounds"]) class OffsetUnitFloatFieldSaveModel(FieldSaveModel): # Note: This is a temperature not a weight. # We wanted to reuse existing test cases inheritance weight = QuantityField("degC") CarliJoy-django-pint-a1179f5/tests/dummyapp/urls.py000066400000000000000000000013621513521510100223260ustar00rootroot00000000000000"""testproject URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.2/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin from django.urls import path urlpatterns = [ path("admin/", admin.site.urls), ] CarliJoy-django-pint-a1179f5/tests/dummyapp/wsgi.py000066400000000000000000000006031513521510100223070ustar00rootroot00000000000000""" WSGI config for testproject project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") application = get_wsgi_application() CarliJoy-django-pint-a1179f5/tests/manage.py000077500000000000000000000012201513521510100207310ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() CarliJoy-django-pint-a1179f5/tests/settings.py000066400000000000000000000057521513521510100213540ustar00rootroot00000000000000import os from pathlib import Path from pint import UnitRegistry # Try to find guess the correct loading string for the dummy app, # which depends on the PYTHON_PATH (that can differ between local # testing and a pytest run. dummy_app_load_string: str = "" try: import tests.dummyapp # noqa except ImportError: try: import dummyapp # noqa except ImportError: raise ImportError( "Neither `tests.dummyapp' nor 'dummyapp' has been " " found in the PYTHON_PATH." ) from None else: dummy_app_load_string = "dummyapp" else: dummy_app_load_string = "tests.dummyapp" # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent ALLOWED_HOSTS = ["127.0.0.1", "localhost"] DEBUG = True STATIC_URL = "/static/" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "USER": os.environ.get("POSTGRES_USER", "django_pint"), "NAME": os.environ.get("POSTGRES_DB", "django_pint"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": os.environ.get( "POSTGRES_PORT", os.environ.get("POSTGRES_5432_TCP_PORT", "") ), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "not_secure_in_testing"), "TEST": { "NAME": os.environ.get("TEST_DB", "mytestdatabase"), }, }, } # not very secret in tests SECRET_KEY = "5tb#evac8q447#b7u8w5#yj$yq3%by!a-5t7$4@vrj$al1-u3c" # noqa: S105 USE_I18N = True USE_L10N = True # Use common Middleware MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "quantityfield", dummy_app_load_string, ] ROOT_URLCONF = f"{dummy_app_load_string}.urls" custom_ureg = UnitRegistry() custom_ureg.define("custom = [custom]") custom_ureg.define("kilocustom = 1000 * custom") DJANGO_PINT_UNIT_REGISTER = custom_ureg WSGI_APPLICATION = f"{dummy_app_load_string}.wsgi.application" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" CarliJoy-django-pint-a1179f5/tests/test_admin.py000066400000000000000000000026771513521510100216460ustar00rootroot00000000000000import pytest import django.contrib.admin from django.contrib.admin import ModelAdmin from django.db.models import Model from django.forms import Field, ModelForm from quantityfield.widgets import QuantityWidget from tests.dummyapp import models @pytest.mark.parametrize( "model, field", [ (models.FloatFieldSaveModel, "weight"), (models.IntFieldSaveModel, "weight"), (models.BigIntFieldSaveModel, "weight"), (models.DecimalFieldSaveModel, "weight"), (models.HayBale, "weight"), (models.HayBale, "weight_int"), (models.HayBale, "weight_bigint"), (models.EmptyHayBaleFloat, "weight"), (models.EmptyHayBaleInt, "weight"), (models.EmptyHayBaleBigInt, "weight"), (models.EmptyHayBaleDecimal, "weight"), (models.CustomUregHayBale, "custom"), (models.CustomUregHayBale, "custom_int"), (models.CustomUregHayBale, "custom_bigint"), (models.CustomUregDecimalHayBale, "custom_decimal"), (models.ChoicesDefinedInModel, "weight"), (models.ChoicesDefinedInModelInt, "weight"), ], ) def test_admin_widgets(model: Model, field: str): """ Test that all admin pages deliver the correct widget """ admin: ModelAdmin = django.contrib.admin.site._registry[model] form: ModelForm = admin.get_form({})() form_fields: dict[str, Field] = form.fields assert type(form_fields[field].widget) == QuantityWidget # noqa CarliJoy-django-pint-a1179f5/tests/test_field.py000066400000000000000000000476701513521510100216430ustar00rootroot00000000000000import json import warnings from decimal import Decimal import pytest import django.core.exceptions import django.core.validators from django.core.serializers import deserialize, serialize from django.db import transaction from django.db.models import Field, Model from django.test import TestCase from pint import DimensionalityError, UndefinedUnitError, UnitRegistry from quantityfield.fields import ( BigIntegerQuantityField, DecimalQuantityField, DecimalQuantityFormField, IntegerQuantityField, PositiveIntegerQuantityField, QuantityField, QuantityFieldMixin, ) from quantityfield.units import ureg from tests.dummyapp.models import ( BigIntFieldSaveModel, CustomUregDecimalHayBale, CustomUregHayBale, DecimalFieldSaveModel, EmptyHayBaleBigInt, EmptyHayBaleDecimal, EmptyHayBaleFloat, EmptyHayBaleInt, EmptyHayBalePositiveInt, FieldSaveModel, FloatFieldSaveModel, IntFieldSaveModel, OffsetUnitFloatFieldSaveModel, ) Quantity = ureg.Quantity class BaseMixinTestFieldCreate: # The field that needs to be tested FIELD: type[Field | QuantityFieldMixin] # Some fields, i.e. the decimal require default kwargs to work properly DEFAULT_KWARGS = {} def test_sets_units(self): test_grams = self.FIELD("gram", **self.DEFAULT_KWARGS) self.assertEqual(test_grams.units, ureg.gram) def test_fails_with_unknown_units(self): with self.assertRaises(UndefinedUnitError): test_crazy_units = self.FIELD( # noqa: F841 "zinghie", **self.DEFAULT_KWARGS ) def test_base_units_is_required(self): with self.assertRaises(TypeError): no_units = self.FIELD(**self.DEFAULT_KWARGS) # noqa: F841 def test_base_units_set_with_name(self): okay_units = self.FIELD(base_units="meter", **self.DEFAULT_KWARGS) # noqa: F841 def test_base_units_are_invalid(self): with self.assertRaises(ValueError): wrong_units = self.FIELD(None, **self.DEFAULT_KWARGS) # noqa: F841 def test_unit_choices_must_be_valid_units(self): with self.assertRaises(UndefinedUnitError): self.FIELD(base_units="mile", unit_choices=["gunzu"], **self.DEFAULT_KWARGS) def test_unit_choices_must_match_base_dimensionality(self): with self.assertRaises(DimensionalityError): self.FIELD( base_units="gram", unit_choices=["meter", "ounces"], **self.DEFAULT_KWARGS, ) class TestFloatFieldCreate(BaseMixinTestFieldCreate, TestCase): FIELD = QuantityField class TestIntegerFieldCreate(BaseMixinTestFieldCreate, TestCase): FIELD = IntegerQuantityField class TestBigIntegerFieldCreate(BaseMixinTestFieldCreate, TestCase): FIELD = BigIntegerQuantityField class TestPositiveIntegerFieldCreate(BaseMixinTestFieldCreate, TestCase): FIELD = PositiveIntegerQuantityField class TestDecimalFieldCreate(BaseMixinTestFieldCreate, TestCase): FIELD = DecimalQuantityField DEFAULT_KWARGS = {"max_digits": 10, "decimal_places": 2} @pytest.mark.parametrize( "max_digits, decimal_places, error", [ (None, None, "Invalid initialization.*expect.*integers.*"), (10, None, "Invalid initialization.*expect.*integers.*"), (None, 2, "Invalid initialization.*expect.*integers.*"), (-1, 2, "Invalid initialization.*positive.*larger than decimal_places.*"), (2, -1, "Invalid initialization.*positive.*larger than decimal_places.*"), (2, 3, "Invalid initialization.*positive.*larger than decimal_places.*"), ], ) def test_decimal_init_fail(max_digits, decimal_places, error): with pytest.raises(ValueError, match=error): DecimalQuantityField( "meter", max_digits=max_digits, decimal_places=decimal_places ) @pytest.mark.parametrize("max_digits, decimal_places", [(2, 0), (2, 2), (1, 0)]) def decimal_init_success(max_digits, decimal_places): DecimalQuantityField("meter", max_digits=max_digits, decimal_places=decimal_places) @pytest.mark.django_db class TestCustomDecimalUreg(TestCase): def setUp(self): # Custom Values are fined in confest.py CustomUregDecimalHayBale.objects.create(custom_decimal=Decimal("5")) CustomUregDecimalHayBale.objects.create( custom_decimal=Decimal("5") * ureg.kilocustom, ) def tearDown(self): CustomUregHayBale.objects.all().delete() def test_custom_ureg_decimal(self): obj = CustomUregDecimalHayBale.objects.first() self.assertEqual(str(obj.custom_decimal), "5.00 custom") obj = CustomUregDecimalHayBale.objects.last() self.assertEqual(str(obj.custom_decimal), "5000.00 custom") @pytest.mark.django_db class TestCustomUreg(TestCase): def setUp(self): # Custom Values are fined in confest.py CustomUregHayBale.objects.create(custom=5, custom_int=5, custom_bigint=5) CustomUregHayBale.objects.create( custom=5 * ureg.kilocustom, custom_int=5 * ureg.kilocustom, custom_bigint=5 * ureg.kilocustom, ) def tearDown(self): CustomUregHayBale.objects.all().delete() def test_custom_ureg_float(self): obj = CustomUregHayBale.objects.first() self.assertIsInstance(obj.custom, ureg.Quantity) self.assertEqual(str(obj.custom), "5.0 custom") obj = CustomUregHayBale.objects.last() self.assertEqual(str(obj.custom), "5000.0 custom") def test_custom_ureg_int(self): obj = CustomUregHayBale.objects.first() self.assertIsInstance(obj.custom_int, ureg.Quantity) self.assertEqual(str(obj.custom_int), "5 custom") obj = CustomUregHayBale.objects.last() self.assertEqual(str(obj.custom_int), "5000 custom") def test_custom_ureg_bigint(self): obj = CustomUregHayBale.objects.first() self.assertIsInstance(obj.custom_int, ureg.Quantity) self.assertEqual(str(obj.custom_bigint), "5 custom") obj = CustomUregHayBale.objects.last() self.assertEqual(str(obj.custom_bigint), "5000 custom") class BaseMixinNullAble: EMPTY_MODEL: type[Model] FLOAT_SET_STR = "707.7" FLOAT_SET = float(FLOAT_SET_STR) DB_FLOAT_VALUE_EXPECTED = 707.7 def setUp(self): self.EMPTY_MODEL.objects.create(name="Empty") def tearDown(self) -> None: self.EMPTY_MODEL.objects.all().delete() def test_accepts_assigned_null(self): new = self.EMPTY_MODEL() new.weight = None new.name = "Test" new.save() self.assertIsNone(new.weight) # Also get it from database to verify from_db = self.EMPTY_MODEL.objects.last() self.assertIsNone(from_db.weight) def test_accepts_auto_null(self): empty = self.EMPTY_MODEL.objects.first() self.assertIsNone(empty.weight, None) def test_accepts_default_pint_unit(self): new = self.EMPTY_MODEL(name="DefaultPintUnitTest") units = UnitRegistry() new.weight = 5 * units.kilogram # Different Registers so we expect a warning! with self.assertWarns(RuntimeWarning): new.save() obj = self.EMPTY_MODEL.objects.last() self.assertEqual(obj.name, "DefaultPintUnitTest") self.assertEqual(obj.weight.units, "gram") self.assertEqual(obj.weight.magnitude, 5000) def test_accepts_default_app_unit(self): new = self.EMPTY_MODEL(name="DefaultAppUnitTest") new.weight = 5 * ureg.kilogram # Make sure that the correct argument does not raise a warning with warnings.catch_warnings(record=True) as w: new.save() assert len(w) == 0 obj = self.EMPTY_MODEL.objects.last() self.assertEqual(obj.name, "DefaultAppUnitTest") self.assertEqual(obj.weight.units, "gram") self.assertEqual(obj.weight.magnitude, 5000) def test_accepts_assigned_whole_number(self): new = self.EMPTY_MODEL(name="WholeNumber") new.weight = 707 new.save() obj = self.EMPTY_MODEL.objects.last() self.assertEqual(obj.name, "WholeNumber") self.assertEqual(obj.weight.units, "gram") self.assertEqual(obj.weight.magnitude, 707) def test_accepts_assigned_float_number(self): new = self.EMPTY_MODEL(name="FloatNumber") new.weight = self.FLOAT_SET new.save() obj = self.EMPTY_MODEL.objects.last() self.assertEqual(obj.name, "FloatNumber") self.assertEqual(obj.weight.units, "gram") # We expect the database to deliver the correct type, at least # for postgresql this is true self.assertEqual(obj.weight.magnitude, self.DB_FLOAT_VALUE_EXPECTED) self.assertIsInstance(obj.weight.magnitude, type(self.DB_FLOAT_VALUE_EXPECTED)) def test_serialisation(self): serialized = serialize( "json", [ self.EMPTY_MODEL.objects.first(), ], ) deserialized = json.loads(serialized) obj = deserialized[0]["fields"] self.assertEqual(obj["name"], "Empty") self.assertIsNone(obj["weight"]) obj_generator = deserialize("json", serialized, ignorenonexistent=True) obj_back = next(obj_generator) self.assertEqual(obj_back.object.name, "Empty") self.assertIsNone(obj_back.object.weight) @pytest.mark.django_db class TestNullableFloat(BaseMixinNullAble, TestCase): EMPTY_MODEL = EmptyHayBaleFloat @pytest.mark.django_db class TestNullableInt(BaseMixinNullAble, TestCase): EMPTY_MODEL = EmptyHayBaleInt DB_FLOAT_VALUE_EXPECTED = int(BaseMixinNullAble.FLOAT_SET) @pytest.mark.django_db class TestNullablePositiveInt(BaseMixinNullAble, TestCase): EMPTY_MODEL = EmptyHayBalePositiveInt DB_FLOAT_VALUE_EXPECTED = int(BaseMixinNullAble.FLOAT_SET) def test_raises_validation_error_for_negative_numbers(self): # Test copied and modified from Django IntegerField tests min_value = 0 instance = self.EMPTY_MODEL(weight=min_value - 1, name="lowest") expected_message = django.core.validators.MinValueValidator.message % { "limit_value": min_value, } with self.assertRaisesMessage( django.core.exceptions.ValidationError, expected_message ): instance.full_clean() instance.weight = min_value instance.full_clean() @pytest.mark.django_db class TestNullableBigInt(BaseMixinNullAble, TestCase): EMPTY_MODEL = EmptyHayBaleBigInt DB_FLOAT_VALUE_EXPECTED = int(BaseMixinNullAble.FLOAT_SET) @pytest.mark.django_db class TestNullableDecimal(BaseMixinNullAble, TestCase): EMPTY_MODEL = EmptyHayBaleDecimal DB_FLOAT_VALUE_EXPECTED = Decimal(BaseMixinNullAble.FLOAT_SET_STR) def test_with_default_implementation(self): new = self.EMPTY_MODEL(name="FloatNumber") new.weight = self.FLOAT_SET new.compare = self.FLOAT_SET new.save() obj = self.EMPTY_MODEL.objects.last() self.assertEqual(obj.name, "FloatNumber") self.assertEqual(obj.weight.units, "gram") # We compare with the reference implementation of django, this should # be always true no matter which database is used self.assertEqual(obj.weight.magnitude, obj.compare) self.assertIsInstance(obj.weight.magnitude, type(obj.compare)) def test_with_decimal(self): new = self.EMPTY_MODEL(name="FloatNumber") new.weight = Decimal(self.FLOAT_SET_STR) new.compare = Decimal(self.FLOAT_SET_STR) new.save() obj = self.EMPTY_MODEL.objects.last() self.assertEqual(obj.name, "FloatNumber") self.assertEqual(obj.weight.units, "gram") # We compare with the reference implementation of django, this should # be always true no matter which database is used self.assertEqual(obj.weight.magnitude, obj.compare) self.assertIsInstance(obj.weight.magnitude, type(obj.compare)) # But we also expect (at least for postgresql) that this a Decimal self.assertEqual(obj.weight.magnitude, self.DB_FLOAT_VALUE_EXPECTED) self.assertIsInstance(obj.weight.magnitude, Decimal) class FieldSaveTestBase: MODEL: type[FieldSaveModel] EXPECTED_TYPE: type = float DEFAULT_WEIGHT = 100 DEFAULT_WEIGHT_STR = "100.0" DEFAULT_WEIGHT_QUANTITY_STR = "100.0 gram" HEAVIEST = 1000 LIGHTEST = 1 OUNCE_VALUE = 3.52739619496 COMPARE_QUANTITY = Quantity(0.8 * ureg.ounce) # 1 ounce = 28.34 grams def setUp(self): self.MODEL.objects.create( weight=self.DEFAULT_WEIGHT, name="grams", ) self.lightest = self.MODEL.objects.create(weight=self.LIGHTEST, name="lightest") self.heaviest = self.MODEL.objects.create(weight=self.HEAVIEST, name="heaviest") def tearDown(self): self.MODEL.objects.all().delete() def test_fails_with_incompatible_units(self): # we have to wrap this in a transaction # fixing a unit test problem # http://stackoverflow.com/questions/21458387/transactionmanagementerror-you-cant-execute-queries-until-the-end-of-the-atom metres = Quantity(100 * ureg.meter) with transaction.atomic(), self.assertRaises(DimensionalityError): self.MODEL.objects.create(weight=metres, name="Should Fail") def test_value_stored_as_quantity(self): obj = self.MODEL.objects.first() self.assertIsInstance(obj.weight, Quantity) self.assertEqual(str(obj.weight), self.DEFAULT_WEIGHT_QUANTITY_STR) def test_value_stored_as_correct_magnitude_type(self): obj = self.MODEL.objects.first() self.assertIsInstance(obj.weight, Quantity) self.assertIsInstance(obj.weight.magnitude, self.EXPECTED_TYPE) def test_value_conversion(self): obj = self.MODEL.objects.first() ounces = obj.weight.to(ureg.ounce) self.assertAlmostEqual(ounces.magnitude, self.OUNCE_VALUE) self.assertEqual(ounces.units, ureg.ounce) def test_order_by(self): qs = list(self.MODEL.objects.all().order_by("weight")) self.assertEqual(qs[0].name, "lightest") self.assertEqual(qs[-1].name, "heaviest") self.assertEqual(qs[0], self.lightest) self.assertEqual(qs[-1], self.heaviest) def test_comparison_with_number(self): qs = self.MODEL.objects.filter(weight__gt=2) self.assertNotIn(self.lightest, qs) def test_comparison_with_quantity(self): weight = Quantity(20 * ureg.gram) qs = self.MODEL.objects.filter(weight__gt=weight) self.assertNotIn(self.lightest, qs) def test_comparison_with_quantity_respects_units(self): qs = self.MODEL.objects.filter(weight__gt=self.COMPARE_QUANTITY) self.assertNotIn(self.lightest, qs) def test_comparison_is_actually_numeric(self): qs = self.MODEL.objects.filter(weight__gt=1.0) self.assertNotIn(self.lightest, qs) def test_serialisation(self): serialized = serialize( "json", [ self.MODEL.objects.first(), ], ) deserialized = json.loads(serialized) obj = deserialized[0]["fields"] self.assertEqual(obj["weight"], self.DEFAULT_WEIGHT_STR) class FloatLikeFieldSaveTestBase(FieldSaveTestBase): OUNCES = Quantity(10 * ureg.ounce) OUNCES_IN_GRAM = 283.49523125 def test_stores_value_in_base_units(self): self.MODEL.objects.create(weight=self.OUNCES, name="ounce") item = self.MODEL.objects.get(name="ounce") self.assertEqual(item.weight.units, "gram") self.assertAlmostEqual(item.weight.magnitude, self.OUNCES_IN_GRAM) class TestFloatFieldSave(FloatLikeFieldSaveTestBase, TestCase): MODEL = FloatFieldSaveModel class TestDecimalFieldSave(FloatLikeFieldSaveTestBase, TestCase): MODEL = DecimalFieldSaveModel DEFAULT_WEIGHT_STR = "100.00" DEFAULT_WEIGHT_QUANTITY_STR = "100.00 gram" OUNCES = Decimal("10") * ureg.ounce OUNCE_VALUE = Decimal("3.52739619496") OUNCES_IN_GRAM = Decimal("283.50") EXPECTED_TYPE = Decimal def test_stores_value_in_base_units(self): field = self.MODEL._meta.get_field("weight") expected = Decimal("2.1") func = field.to_number_type self.assertIsInstance(func(2.1), Decimal) self.assertEqual(func(2.1), expected) # test float self.assertEqual(func("2.1"), expected) # test string self.assertEqual(func(Decimal("2.1")), expected) # test Decimal self.assertEqual(func(2), Decimal("2")) # test Int class IntLikeFieldSaveTestBase(FieldSaveTestBase): DEFAULT_WEIGHT_STR = "100" DEFAULT_WEIGHT_QUANTITY_STR = "100 gram" EXPECTED_TYPE = int # 1 ounce = 28.34 grams -> we use something that can be stored as int COMPARE_QUANTITY = Quantity(28 * 1000 * ureg.milligram) @pytest.mark.xfail(reason="Not anymore supported") def test_store_integer_loss_of_precision(self): # We don't support this anymore, as it introduces to many edge cases # Also the normal int field accepts floats, so this should be handled # by the forms! with transaction.atomic(): with self.assertRaisesRegex(ValueError, "loss of precision"): self.MODEL(name="x", weight=Quantity(10 * ureg.ounce)).save() class TestIntFieldSave(IntLikeFieldSaveTestBase, TestCase): MODEL = IntFieldSaveModel class TestBigIntFieldSave(IntLikeFieldSaveTestBase, TestCase): MODEL = BigIntFieldSaveModel class TestOffsetUnitFieldSaveTestBase(FloatLikeFieldSaveTestBase, TestCase): MODEL = OffsetUnitFloatFieldSaveModel DEFAULT_WEIGHT_QUANTITY_STR = "100.0 degree_Celsius" HEAVIEST = 1000 LIGHTEST = 1 FAHRENHEIT_VALUE = 212 # 100 celsius = 212 fahrenheit COMPARE_QUANTITY = Quantity(100, ureg.fahrenheit) # Note: weight here is a temperature, # reused the field name to allow inheritance of float test cae def test_value_conversion(self): obj = self.MODEL.objects.first() degF = obj.weight.to(ureg.fahrenheit) # weight is in celsius self.assertAlmostEqual(degF.magnitude, self.FAHRENHEIT_VALUE) self.assertEqual(degF.units, ureg.fahrenheit) def test_value_stored_as_quantity(self): obj = self.MODEL.objects.first() self.assertIsInstance(obj.weight, Quantity) self.assertEqual(str(obj.weight), self.DEFAULT_WEIGHT_QUANTITY_STR) def test_stores_value_in_base_units(self): self.MODEL.objects.create(weight=self.FAHRENHEIT_VALUE, name="fahrenheit") item = self.MODEL.objects.get(name="fahrenheit") self.assertEqual(item.weight.units, "degree_Celsius") self.assertAlmostEqual(item.weight.magnitude, self.FAHRENHEIT_VALUE) def test_comparison_with_quantity(self): weight = Quantity(20, ureg.celsius) qs = self.MODEL.objects.filter(weight__gt=weight) self.assertNotIn(self.lightest, qs) def test_comparison_with_quantity_respects_units(self): qs = self.MODEL.objects.filter(weight__gt=self.COMPARE_QUANTITY) self.assertNotIn(self.lightest, qs) class TestDecimalQuantityFormField(TestCase): def test_to_number_type_returns_decimal(self): field = DecimalQuantityFormField(base_units="gram") expected = Decimal("2.1") func = field.to_number_type self.assertIsInstance(func(2.1), Decimal) self.assertEqual(func(2.1), expected) # test float self.assertEqual(func("2.1"), expected) # test string self.assertEqual(func(Decimal("2.1")), expected) # test Decimal self.assertEqual(func(2), Decimal("2")) # test Int def test_saves_correct_decimal_precision(self): field = DecimalQuantityFormField(base_units="gram") expected = Decimal("2.1") self.assertEqual(field.clean(2.1).magnitude, expected) # test float self.assertEqual(field.clean("2.1").magnitude, expected) # test string self.assertEqual(field.clean(expected).magnitude, expected) # test Decimal self.assertEqual(field.clean(2).magnitude, Decimal("2")) # test Int CarliJoy-django-pint-a1179f5/tests/test_field_orm.py000066400000000000000000000044011513521510100225010ustar00rootroot00000000000000from decimal import Decimal import pytest from django.db.models import Min, Subquery from django.test import TestCase from quantityfield.units import ureg from tests.dummyapp.models import ( BigIntFieldSaveModel, DecimalFieldSaveModel, EmptyHayBalePositiveInt, FloatFieldSaveModel, IntFieldSaveModel, ) Quantity = ureg.Quantity class BaseMixinQuantityFieldORM: """Base mixin for ORM tests for QuantityField types.""" MODEL: type FIELD_NAME: str = "weight" CREATE_KWARGS_LIGHT: dict = {"name": "light", FIELD_NAME: 100} CREATE_KWARGS_HEAVY: dict = {"name": "heavy", FIELD_NAME: 200} EXPECTED_TYPE: type def setUp(self): self.light = self.MODEL.objects.create(**self.CREATE_KWARGS_LIGHT) self.heavy = self.MODEL.objects.create(**self.CREATE_KWARGS_HEAVY) def tearDown(self): self.MODEL.objects.all().delete() def test_bulk_update_with_subquery(self): min_value_qs = self.MODEL.objects.annotate( min_value=Min(self.FIELD_NAME) ).values("min_value")[:1] self.MODEL.objects.all().update(**{self.FIELD_NAME: Subquery(min_value_qs)}) self.light.refresh_from_db() self.heavy.refresh_from_db() self.assertEqual( Quantity(self.EXPECTED_TYPE(100) * ureg.gram), getattr(self.light, self.FIELD_NAME), ) self.assertEqual( Quantity(self.EXPECTED_TYPE(100) * ureg.gram), getattr(self.heavy, self.FIELD_NAME), ) @pytest.mark.django_db class TestDecimalQuantityFieldORM(BaseMixinQuantityFieldORM, TestCase): MODEL = DecimalFieldSaveModel EXPECTED_TYPE = Decimal @pytest.mark.django_db class TestFloatQuantityFieldORM(BaseMixinQuantityFieldORM, TestCase): MODEL = FloatFieldSaveModel EXPECTED_TYPE = float @pytest.mark.django_db class TestIntegerQuantityFieldORM(BaseMixinQuantityFieldORM, TestCase): MODEL = IntFieldSaveModel EXPECTED_TYPE = int @pytest.mark.django_db class TestBigIntegerQuantityFieldORM(BaseMixinQuantityFieldORM, TestCase): MODEL = BigIntFieldSaveModel EXPECTED_TYPE = int @pytest.mark.django_db class TestPositiveIntegerQuantityFieldORM(BaseMixinQuantityFieldORM, TestCase): MODEL = EmptyHayBalePositiveInt EXPECTED_TYPE = int CarliJoy-django-pint-a1179f5/tests/test_helper.py000066400000000000000000000103421513521510100220210ustar00rootroot00000000000000from django.test import TestCase from pint import Context, DimensionalityError import quantityfield.fields as fields import quantityfield.helper as helper from quantityfield.units import ureg class TestMatchingUnitDimensionsHelper(TestCase): def test_valid_choices(self): helper.check_matching_unit_dimension(ureg, "meter", ["mile", "foot", "cm"]) def test_invalid_choices(self): with self.assertRaises(DimensionalityError): helper.check_matching_unit_dimension( ureg, "meter", ["mile", "foot", "cm", "kg"] ) class TestEdgeCases(TestCase): def test_fix_unit_registry(self): field = fields.IntegerQuantityField("meter") with self.assertRaises(ValueError): field.fix_unit_registry(1) def test_get_prep_value(self): field = fields.IntegerQuantityField("meter") with self.assertRaises(ValueError): field.get_prep_value("foobar") class TestContextHandling(TestCase): """Class to test ureg.context for compatible units as described in issue #99. pint allows users to define a special context for unit conversions, e.g. on earth a mass can directly be converted to a force given the acceleration 'constant' on earth. We will test the unit compatibility via the helper function for both both context activated globally via ureg and within a with block. Finally test the conversion integrated inside an IntegerQuantityField. Also the negatives are tested: without the context, mass should not be accepted as a matching unit for force. """ def setUp(self): """Setup a pint context in the UnitRegistry.""" # Define a context where mass is equated to force via earth's # standard acceleration of gravity and vice versa # (https://en.wikipedia.org/wiki/Standard_gravity) self.context = Context("earth") # mass -> force self.context.add_transformation( "[mass]", "[force]", lambda ureg, x: x * ureg.gravity ) # force -> mass self.context.add_transformation( "[force]", "[mass]", lambda ureg, x: x / ureg.gravity ) ureg.add_context(self.context) def test_context_global(self): """Activate ureg.context globally and test conversion compatibility directly.""" # Activate context globally and test ureg.enable_contexts("earth") helper.check_matching_unit_dimension(ureg, "kg", ["newton", "kN", "ton"]) ureg.disable_contexts() def test_context_with_block(self): """Activate ureg.context in with block and test conversion compatibility directly.""" # Use context with the 'with' statement with ureg.context("earth"): helper.check_matching_unit_dimension(ureg, "kg", ["newton", "kN", "ton"]) def test_invalid_context(self): """Negative test: Conversion mass to force should fail without context.""" with self.assertRaises(DimensionalityError): helper.check_matching_unit_dimension(ureg, "kg", ["newton", "kN", "ton"]) def test_field_w_context_global(self): """Negative test: Conversion mass to force should fail without context.""" ureg.enable_contexts("earth") self.field = fields.IntegerQuantityField( base_units="kg", unit_choices=["newton", "kN", "ton"] ) ureg.disable_contexts() def test_field_w_context_block(self): """Activate ureg.context globally and test conversion compatibility complete Field.""" with ureg.context("earth"): self.field = fields.IntegerQuantityField( base_units="kg", unit_choices=["newton", "kN", "ton"] ) def test_invalid_field_wo_context(self): """Negative test: Conversion mass to force should fail without context.""" with self.assertRaises(DimensionalityError): self.field = fields.IntegerQuantityField( base_units="kg", unit_choices=["newton", "kN", "ton"] ) def tearDown(self): """Disable and remove the contexts to not interfere with other tests.""" # Clean up by disabling and removing the context ureg.disable_contexts() ureg.remove_context("earth") CarliJoy-django-pint-a1179f5/tests/test_imports.py000066400000000000000000000007041513521510100222400ustar00rootroot00000000000000import django_pint import quantityfield def test_version_import_quantityfield() -> None: """The quantityfield version is a defined version""" assert quantityfield.__version__ != "unknown" assert quantityfield.__version__[0].isnumeric() def test_version_import_django_pint() -> None: """The django_pint version is a defined version""" assert django_pint.__version__ != "unknown" assert django_pint.__version__[0].isnumeric() CarliJoy-django-pint-a1179f5/tests/test_integration.py000066400000000000000000000066531513521510100230770ustar00rootroot00000000000000from decimal import Decimal import pytest from django import forms from django.test import TestCase from tests.dummyapp.forms import ( DefaultFormBigInt, DefaultFormDecimal, DefaultFormFieldsBigInt, DefaultFormFieldsDecimal, DefaultFormFieldsFloat, DefaultFormFieldsInt, DefaultFormFloat, DefaultFormInt, DefaultWidgetsFormBigInt, DefaultWidgetsFormDecimal, DefaultWidgetsFormFloat, DefaultWidgetsFormInt, ) class IntegrationTestBase: DEFAULT_FORM = DefaultFormFloat DEFAULT_FIELDS_FORM = DefaultFormFieldsFloat DEFAULT_WIDGET_FORM = DefaultWidgetsFormFloat INPUT_STR = "10.3" OUTPUT_MAGNITUDE = 10.3 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Make sure we did no mistake creating the tests assert self.DEFAULT_FORM.Meta.model == self.DEFAULT_FIELDS_FORM.Meta.model assert self.DEFAULT_FORM.Meta.model == self.DEFAULT_WIDGET_FORM.Meta.model def _check_form_and_saved_object(self, form: forms.ModelForm, has_magnitude: bool): self.assertTrue(form.is_valid()) if has_magnitude: self.assertAlmostEqual( form.cleaned_data["weight"].magnitude, self.OUTPUT_MAGNITUDE ) self.assertEqual(str(form.cleaned_data["weight"].units), "gram") else: self.assertAlmostEqual(form.cleaned_data["weight"], self.OUTPUT_MAGNITUDE) form.save() obj = form.Meta.model.objects.last() self.assertEqual(str(obj.weight.units), "gram") if isinstance(self.OUTPUT_MAGNITUDE, float): self.assertAlmostEqual(obj.weight.magnitude, self.OUTPUT_MAGNITUDE) else: self.assertEqual(obj.weight.magnitude, self.OUTPUT_MAGNITUDE) self.assertIsInstance(obj.weight.magnitude, type(self.OUTPUT_MAGNITUDE)) @pytest.mark.django_db def test_widget_valid_inputs_with_units(self): form = self.DEFAULT_FORM( data={ "name": "testing", "weight_0": self.INPUT_STR, "weight_1": "gram", } ) self._check_form_and_saved_object(form, True) @pytest.mark.django_db def test_widget_single_inputs_with_units_and_default_form_fields(self): """ Test with default form fields, will still create the correct database entries """ form = self.DEFAULT_FIELDS_FORM( data={ "name": "testing", "weight": self.INPUT_STR, } ) self._check_form_and_saved_object(form, False) class TestFloatFieldWidgetIntegration(IntegrationTestBase, TestCase): pass class TestDecimalFieldWidgetIntegration(IntegrationTestBase, TestCase): DEFAULT_FORM = DefaultFormDecimal DEFAULT_FIELDS_FORM = DefaultFormFieldsDecimal DEFAULT_WIDGET_FORM = DefaultWidgetsFormDecimal INPUT_STR = "10" OUTPUT_MAGNITUDE = Decimal("10") class IntegrationTestBaseInt(IntegrationTestBase): INPUT_STR = "10" OUTPUT_MAGNITUDE = 10 class TestIntFiledWidgetIntegration(IntegrationTestBaseInt, TestCase): DEFAULT_FORM = DefaultFormInt DEFAULT_FIELDS_FORM = DefaultFormFieldsInt DEFAULT_WIDGET_FORM = DefaultWidgetsFormInt class TestBigIntFiledWidgetIntegration(IntegrationTestBaseInt, TestCase): DEFAULT_FORM = DefaultFormBigInt DEFAULT_FIELDS_FORM = DefaultFormFieldsBigInt DEFAULT_WIDGET_FORM = DefaultWidgetsFormBigInt CarliJoy-django-pint-a1179f5/tests/test_widget.py000066400000000000000000000233551513521510100220350ustar00rootroot00000000000000# flake8: noqa: F841 from decimal import Decimal from django import forms from django.test import TestCase from pint import DimensionalityError, UndefinedUnitError from quantityfield.fields import IntegerQuantityFormField, QuantityFormField from quantityfield.units import ureg from quantityfield.widgets import QuantityWidget from tests.dummyapp.models import ( ChoicesDefinedInModel, ChoicesDefinedInModelInt, HayBale, ) Quantity = ureg.Quantity class HayBaleForm(forms.ModelForm): weight = QuantityFormField(base_units="gram", unit_choices=["ounce", "gram"]) weight_int = IntegerQuantityFormField( base_units="gram", unit_choices=["ounce", "gram", "kilogram"] ) class Meta: model = HayBale fields = ("weight",) class HayBaleFormDefaultWidgets(forms.ModelForm): weight = QuantityFormField( base_units="gram", unit_choices=["ounce", "gram"], widget=forms.NumberInput ) weight_int = IntegerQuantityFormField( base_units="gram", unit_choices=["ounce", "gram"], widget=forms.NumberInput ) class Meta: model = HayBale fields = ("weight",) class UnitChoicesDefinedInModelFieldModelForm(forms.ModelForm): class Meta: model = ChoicesDefinedInModel fields = ["weight"] class UnitChoicesDefinedInModelFieldModelFormInt(forms.ModelForm): class Meta: model = ChoicesDefinedInModelInt fields = ["weight"] class NullableWeightForm(forms.Form): weight = QuantityFormField(base_units="gram", required=False) class UnitChoicesForm(forms.Form): distance = QuantityFormField( base_units="kilometer", unit_choices=["mile", "kilometer", "yard", "feet"] ) class TestWidgets(TestCase): def test_creates_correct_widget_for_modelform(self): form = HayBaleForm() self.assertIsInstance(form.fields["weight"], QuantityFormField) self.assertIsInstance(form.fields["weight"].widget, QuantityWidget) def test_displays_initial_data_correctly(self): form = HayBaleForm( initial={"weight": Quantity(100 * ureg.gram), "name": "test"} ) def test_clean_yields_quantity(self): form = HayBaleForm( data={ "weight_0": 100.0, "weight_1": "gram", "weight_int_0": 100, "weight_int_1": "gram", "name": "test", } ) self.assertTrue(form.is_valid()) self.assertIsInstance(form.cleaned_data["weight"], Quantity) def test_clean_yields_quantity_in_correct_units(self): form = HayBaleForm( data={ "weight_0": 1.0, "weight_1": "ounce", "weight_int_0": 1, "weight_int_1": "kilogram", "name": "test", } ) self.assertTrue(form.is_valid()) self.assertEqual(str(form.cleaned_data["weight"].units), "gram") self.assertAlmostEqual(form.cleaned_data["weight"].magnitude, 28.349523125) self.assertEqual(str(form.cleaned_data["weight_int"].units), "gram") self.assertAlmostEqual(form.cleaned_data["weight_int"].magnitude, 1000) def test_precision_lost(self): def test_clean_yields_quantity_in_correct_units(self): form = HayBaleForm( data={ "weight_0": 1.0, "weight_1": "ounce", "weight_int_0": 1, "weight_int_1": "onuce", "name": "test", } ) self.assertFalse(form.is_valid()) def test_base_units_is_required_for_form_field(self): with self.assertRaises(ValueError): field = QuantityFormField() # noqa: F841 def test_quantityfield_can_be_null(self): form = NullableWeightForm(data={"weight_0": None, "weight_1": None}) self.assertTrue(form.is_valid()) def test_validate_units(self): form = UnitChoicesForm(data={"distance_0": 100, "distance_1": "ounce"}) self.assertFalse(form.is_valid()) def test_base_units_is_included_by_default(self): field = QuantityFormField(base_units="mile", unit_choices=["meters", "feet"]) self.assertIn("mile", field.units) def test_widget_field_displays_unit_choices(self): form = UnitChoicesForm() self.assertListEqual( [ ("mile", "mile"), ("kilometer", "kilometer"), ("yard", "yard"), ("feet", "feet"), ], form.fields["distance"].widget.widgets[1].choices, ) def test_widget_field_displays_unit_choices_for_model_field_propagation(self): form = UnitChoicesDefinedInModelFieldModelForm() self.assertListEqual( [ ("kilogram", "kilogram"), ("milligram", "milligram"), ("pounds", "pounds"), ], form.fields["weight"].widget.widgets[1].choices, ) def test_widget_int_field_displays_unit_choices_for_model_field_propagation(self): form = UnitChoicesDefinedInModelFieldModelFormInt() self.assertListEqual( [ ("kilogram", "kilogram"), ("milligram", "milligram"), ("pounds", "pounds"), ], form.fields["weight"].widget.widgets[1].choices, ) def test_unit_choices_must_be_valid_units(self): with self.assertRaises(UndefinedUnitError): field = QuantityFormField(base_units="mile", unit_choices=["gunzu"]) # noqa: F841 def test_unit_choices_must_match_base_dimensionality(self): with self.assertRaises(DimensionalityError): field = QuantityFormField( base_units="gram", unit_choices=["meter", "ounces"] ) # noqa: F841 def test_widget_invalid_float(self): form = HayBaleForm( data={ "name": "testing", "weight_0": "a", "weight_1": "gram", "weight_int_0": "10", "weight_int_1": "gram", } ) self.assertFalse(form.is_valid()) self.assertIn("weight", form.errors) def test_widget_missing_required_input(self): form = HayBaleForm( data={ "name": "testing", "weight_int_0": "10", "weight_int_1": "gram", } ) self.assertFalse(form.is_valid()) self.assertIn("weight", form.errors) def test_widget_empty_value_for_required_input(self): form = HayBaleForm( data={ "name": "testing", "weight_0": "", "weight_1": "gram", "weight_int_0": "10", "weight_int_1": "gram", } ) self.assertFalse(form.is_valid()) self.assertIn("weight", form.errors) def test_widget_none_value_set_for_required_input(self): form = HayBaleForm( data={ "name": "testing", "weight_0": None, "weight_1": "gram", "weight_int_0": "10", "weight_int_1": "gram", } ) self.assertFalse(form.is_valid()) self.assertIn("weight", form.errors) def test_widget_int_precision_loss(self): form = HayBaleFormDefaultWidgets( data={ "name": "testing", "weight": "10", "weight_int": "10.3", } ) self.assertFalse(form.is_valid()) self.assertTrue(form.has_error("weight_int")) class TestWidgetRenderingBase(TestCase): value = 20 expected_created = "20" expected_db = "20.0" def get_html(self, value_from_db: bool) -> str: """Create the rendered form with the widget""" bale = HayBale.objects.create(name="Fritz", weight=self.value) if value_from_db: # When creating an object django just takes the given value # and sets it # Once we receive it from the database the correct Quantity # is created bale = HayBale.objects.get(pk=bale.pk) form = HayBaleForm(instance=bale) return str(form) def test_widget_display(self): # Add to Integration tests html = self.get_html(False) expected = f'' self.assertIn(expected, html) self.assertIn('', html) def test_widget_display_db_value(self): html = self.get_html(True) expected = f'' self.assertIn(expected, html) self.assertIn('', html) class TestWidgetRenderingNegativeNumber(TestWidgetRenderingBase): value = -20 expected_created = "-20" expected_db = "-20.0" class TestWidgetRenderingSmallNumber(TestWidgetRenderingBase): value = 1e-10 expected_created = "1e-10" expected_db = "1e-10" class TestWidgetRenderingZeroInt(TestWidgetRenderingBase): value = 0 expected_created = "0" expected_db = "0.0" class TestWidgetRenderingZeroFloat(TestWidgetRenderingBase): value = 0.0 expected_created = "0.0" expected_db = "0.0" class TestWidgetRenderingZeroDecimal(TestWidgetRenderingBase): value = Decimal(0.0) expected_created = "0" expected_db = "0.0" class TestWidgetRenderingDecimalFromFloat(TestWidgetRenderingBase): # 1.0 is represenatble in base 2 and base 10, so should return 1 (not 1. + 1e-16 etc) value = Decimal(1.0) expected_created = "1" expected_db = "1.0" CarliJoy-django-pint-a1179f5/tox.ini000066400000000000000000000030761513521510100173100ustar00rootroot00000000000000[tox] minversion = 4.0 ; We don't set requires as we don't want tox-docker in our github pipeline ;requires = tox-docker>4 isolated_build = True envlist = py{310,311,312,313}-django52, py{312,313,314}-django60, [gh-actions] python = 3.10: py310 3.11: py311 3.12: py312 3.13: py313 3.14: py314 [docker:postgres] image = postgres:14-alpine # Environment variables are passed to the container. They are only # available to that container, and not to the testenv, other # containers, or as replacements in other parts of tox.ini environment = POSTGRES_PASSWORD=django_pint_tox POSTGRES_USER=django_pint_tox POSTGRES_DB=django_pint # The healthcheck ensures that tox-docker won't run tests until the # container is up and the command finishes with exit code 0 (success) healthcheck_cmd = PGPASSWORD=$POSTGRES_PASSWORD psql \ --user=$POSTGRES_USER --dbname=$POSTGRES_DB \ --host=127.0.0.1 --quiet --no-align --tuples-only \ -1 --command="SELECT 1" healthcheck_timeout = 1 healthcheck_retries = 30 healthcheck_interval = 1 healthcheck_start_period = 1 [testenv] passenv = POSTGRES_HOST POSTGRES_PORT setenv = DJANGO_SETTINGS_MODULE=tests.settings TOXINIDIR = {toxinidir} POSTGRES_PASSWORD={env:POSTGRES_PASSWORD:django_pint_tox} POSTGRES_USER={env:POSTGRES_USER:django_pint_tox} POSTGRES_DB={env:POSTGRES_DB:django_pint} deps = django52: Django>=5.2,<5.3 django60: Django>=6.0,<6.1 psycopg2-binary pytest pytest-cov pytest-django docker= postgres commands = pytest -vv {posargs}