diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b25aaa0..b99daa4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -167,3 +167,25 @@ jobs: - name: Check type information run: | mypy quantities + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ruff + run: | + python -m pip install -U pip + python -m pip install -U ruff + + - name: Run Ruff + # Advisory: report style issues but do not fail the build while the + # existing codebase is being brought into full Ruff conformance. + # Drop --exit-zero once the codebase is clean. + run: | + ruff check --exit-zero quantities diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ce856f6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,27 @@ +# GitLab CI configuration for the EBRAINS GitLab mirror. +# +# The canonical CI is GitHub Actions (see .github/workflows/). This pipeline +# runs on the EBRAINS GitLab mirror only, and exists primarily to compute and +# publish a code-coverage badge. The badge URL is derived from this project's +# slug on gitlab.ebrains.eu: +# +# https://gitlab.ebrains.eu///badges/master/coverage.svg + +stages: + - test + +coverage: + stage: test + image: python:3.13 + before_script: + - python -m pip install --upgrade pip + - pip install -e ".[test]" + script: + - pytest --cov=quantities --cov-report=term --cov-report=xml + coverage: '/^TOTAL.*\s+(\d+(?:\.\d+)?)%/' + artifacts: + when: always + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7c78f66 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to quantities + +Contributions to *quantities*, whether bug reports, bug fixes, new units or constants, documentation improvements, or new features, are welcome. + +## Reporting bugs and requesting features + +Please use the [GitHub issue +tracker](https://github.com/python-quantities/python-quantities/issues). + +For bug reports, please include: + +- the version of *quantities* and of NumPy you are using, +- your Python version and operating system, +- a minimal example that reproduces the problem, +- the actual output and the output you expected. + +## Submitting changes + +Development happens on GitHub via pull requests against the `master` branch. +A short walkthrough — from cloning the repository to opening your first PR — +is in the [Onboarding guide][onboarding] in the developer documentation, +and the [Development workflow][workflow] page covers branching, review, +versioning and the deprecation policy. + +Before opening a pull request: + +1. Make sure the test suite passes locally (`pytest`). +2. Add a test for any bug fix or new feature. +3. Add an entry to `CHANGES.txt` describing the change. + +[onboarding]: https://python-quantities.readthedocs.io/en/latest/devel/onboarding.html +[workflow]: https://python-quantities.readthedocs.io/en/latest/devel/workflow.html + +## Licensing of contributions + +*quantities* is distributed under the BSD 3-Clause License (see +[`doc/user/license.rst`](doc/user/license.rst)). +By submitting a contribution (for example by opening +a pull request) you agree that your contribution is licensed under the same +license and that you have the right to make it under those terms. + +No separate Contributor License Agreement (CLA) is required. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..66e5635 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +see doc/user/license.rst diff --git a/README.rst b/README.rst index 1eca086..22714f7 100644 --- a/README.rst +++ b/README.rst @@ -11,12 +11,14 @@ supported. Quantities is actively developed, and while the current features and API are stable, test coverage is incomplete so the package is not suggested for mission-critical applications. -|pypi version|_ |Build status|_ +|pypi version|_ |Build status|_ |Coverage Status|_ .. |pypi version| image:: https://img.shields.io/pypi/v/quantities.png .. _`pypi version`: https://pypi.python.org/pypi/quantities .. |Build status| image:: https://github.com/python-quantities/python-quantities/actions/workflows/test.yml/badge.svg?branch=master .. _`Build status`: https://github.com/python-quantities/python-quantities/actions/workflows/test.yml +.. |Coverage Status| image:: https://gitlab.ebrains.eu/NeuralEnsemble/python-quantities/badges/master/coverage.svg +.. _`Coverage Status`: https://gitlab.ebrains.eu/NeuralEnsemble/python-quantities/-/pipelines .. _tutorial: http://python-quantities.readthedocs.io/en/latest/user/tutorial.html @@ -77,6 +79,13 @@ And run:: in the current directory. The master branch is automatically tested by GitHub Actions. +Contributing +------------ +Bug reports, fixes, and pull requests are welcome. Please see +`CONTRIBUTING.md `_ for a quick overview, and the +`developer documentation `_ +for the onboarding guide, architecture overview, and development workflow. + Author ------ quantities was originally written by Darren Dale, and has received contributions from `many people`_. diff --git a/doc/devel/architecture.rst b/doc/devel/architecture.rst new file mode 100644 index 0000000..4d65d2e --- /dev/null +++ b/doc/devel/architecture.rst @@ -0,0 +1,140 @@ +.. _architecture-overview: + +********************* +Architecture Overview +********************* + +This page gives a high-level view of how *quantities* is put together +internally, intended for new contributors who want to understand the design +before making non-trivial changes. + +For the user-facing API, see the :doc:`user guide `. + + +Design rationale +================ + +*quantities* is a NumPy extension library. Rather than implement its own +array machinery, it subclasses :class:`numpy.ndarray` so that +:class:`Quantity` instances behave like regular arrays — they participate +in vectorised arithmetic, broadcasting, ufuncs and indexing — while also +carrying dimensional (unit) information that is validated and propagated +through every operation. + +The design has three pillars: + +1. **Dimensionality** is data, not a string. Units multiply, divide and + raise to integer powers; *quantities* stores those exponents in a + mapping and uses it both for arithmetic and for display. +2. **Conversions** are pure scalar multiplications. The unit registry + produces conversion factors that are simply numbers; the dimensional + bookkeeping lives in the :class:`Dimensionality` mapping. +3. **Interoperability with NumPy is paramount.** *quantities* hooks into + the ufunc machinery so that ``np.sin``, ``np.sqrt``, ``np.add`` and so + on work as expected on :class:`Quantity` arrays. + + +Class hierarchy +=============== + +The core type hierarchy is:: + + numpy.ndarray + Quantity # quantity.py + UnitQuantity # unitquantity.py + UnitLength # quantities/units/length.py + UnitMass # quantities/units/mass.py + UnitTime # quantities/units/time.py + ... # one subclass per SI base dimension + CompoundUnit # preserves compound expressions + UncertainQuantity # uncertainquantity.py + +:class:`Quantity` + Array values plus a ``_dimensionality`` attribute. This is what users + create with expressions such as ``42 * pq.metre``. + +:class:`UnitQuantity` + A scalar :class:`Quantity` with magnitude 1 that represents a named + unit (``pq.metre``, ``pq.second``, …). Every :class:`UnitQuantity` + self-registers in the global ``unit_registry`` on construction. + +:class:`UnitLength`, :class:`UnitMass`, … + SI-base-dimension subclasses of :class:`UnitQuantity`. They behave the + same as :class:`UnitQuantity` but their type marks the base dimension + they represent. + +:class:`CompoundUnit` + A :class:`UnitQuantity` that preserves the compound expression + (e.g. ``m**2/m**3``) instead of simplifying it. Useful for display + when the user wants the original notation kept. + +:class:`UncertainQuantity` + A :class:`Quantity` subclass that carries a ``_uncertainty`` attribute + (itself a :class:`Quantity`) and propagates uncertainty through + arithmetic. + + +Dimensionality +============== + +:class:`Dimensionality` (``dimensionality.py``) is a :class:`dict` +subclass that maps :class:`UnitQuantity` objects to integer exponents. +For example, the dimensionality of a velocity is:: + + {metre: 1, second: -1} + +The class supports several text representations — ``.string``, ``.unicode``, +``.latex`` and ``.html`` — produced by ``markup.py``. Dimension checking +and conversion-factor computation happen in ``quantity.py`` +(``get_conversion_factor``, ``validate_dimensionality``). + + +The unit registry +================= + +:class:`UnitRegistry` (``registry.py``) is a singleton that maps string +names and symbols to :class:`UnitQuantity` objects. When the user writes:: + + q.units = "kg*m/s**2" + +the string is parsed by the registry. To avoid arbitrary code execution, +the parser uses Python's :mod:`ast` module and only allows a restricted +set of node types (names, numeric literals, binary arithmetic, power). +This is safer than ``eval`` and is verified by the test suite. + +:class:`UnitQuantity` subclasses register themselves in this registry on +construction via their ``__init__``, so importing a module from +``quantities/units/`` is enough to make all the units it defines +available. + + +Math support +============ + +``umath.py`` wraps NumPy ufuncs and reductions (trigonometric functions, +``cumsum``, ``gradient``, ``trapz``, …) so they handle dimensional +analysis correctly. For example, ``pq.sin`` checks that the input has +units of angle, whereas plain ``np.sin`` silently extracts the magnitude. + +The ``known issues`` section of the user guide documents which NumPy +operations are not yet dimension-aware. + + +The ``PREFERRED`` list +====================== + +``quantity.py`` defines a module-level list called ``PREFERRED`` which +downstream packages can mutate to express their preferred units for +display and simplification. This is the main extension point used by +domain-specific consumers of *quantities*. + + +Units and constants data +======================== + +* ``quantities/units/`` — one module per physical dimension. Each module + defines :class:`UnitQuantity` instances which self-register. +* ``quantities/constants/`` — physical constants as :class:`UnitConstant` + objects (a subclass of :class:`UncertainQuantity`). The values come + from ``NIST_codata.txt``; ``_codata.py`` is auto-generated from that + file by ``python setup.py data``. diff --git a/doc/devel/devnotes.rst b/doc/devel/devnotes.rst index e48ce96..a3c454a 100644 --- a/doc/devel/devnotes.rst +++ b/doc/devel/devnotes.rst @@ -1,8 +1,13 @@ +.. _development: + Development =========== Quantities development uses the principles of test-driven development. New -features or bug fixes need to be accompanied by unit tests based on Python's -unittest package. Unit tests can be run with the following:: +features or bug fixes need to be accompanied by unit tests, which can be run +with:: pytest + +For a full walkthrough of setting up an environment, the project layout, and +submitting your first contribution, see :ref:`onboarding`. diff --git a/doc/devel/index.rst b/doc/devel/index.rst index 759cefa..68481e3 100644 --- a/doc/devel/index.rst +++ b/doc/devel/index.rst @@ -7,6 +7,10 @@ Developer's Guide .. toctree:: :maxdepth: 2 - documenting.rst - devnotes.rst + onboarding + architecture + workflow + documenting + devnotes release + maintenance diff --git a/doc/devel/maintenance.rst b/doc/devel/maintenance.rst new file mode 100644 index 0000000..197f348 --- /dev/null +++ b/doc/devel/maintenance.rst @@ -0,0 +1,79 @@ +.. _maintenance: + +*********** +Maintenance +*********** + +This page collects the routine maintenance tasks that come up between +releases. For the release procedure itself see :ref:`release-process`, +and for the general workflow see :ref:`development-workflow`. + + +Updating dependencies +===================== + +*quantities* has a single runtime dependency (NumPy) and a handful of +optional ones (``pytest`` for tests, ``sphinx`` for docs, ``ruff`` for +linting, ``scipy`` as an optional backend for numerical integration). + +When NumPy releases a new minor version: + +1. Add the new version to the ``numpy-version`` matrix in + ``.github/workflows/test.yml`` (both the ``test`` and ``type-check`` + jobs). Update the ``exclude`` list to reflect supported + Python × NumPy combinations as needed. +2. Run the test suite locally against the new NumPy:: + + pip install "numpy==X.Y.*" + pytest + +3. Fix any regressions and record the new supported version in + ``CHANGES.txt``. + +When dropping support for an old Python or NumPy version, also bump +``requires-python`` and the ``numpy`` lower bound in ``pyproject.toml`` +and update the ``target-version`` of the ``[tool.ruff]`` config. + + +Rebuilding the documentation locally +==================================== + +The published documentation is rebuilt automatically by Read the Docs on +every push to ``master``. To rebuild locally during development:: + + pip install -e ".[doc]" + cd doc + make html + +The HTML is written to ``doc/_build/html/``. Open ``index.html`` in your +browser to preview. + +The Read the Docs build is configured by ``.readthedocs.yaml`` (Python +version, build OS, requirements file) and ``doc/conf.py`` (Sphinx +extensions, theme, project metadata). + + +Coordinating with downstream packagers +====================================== + +*quantities* is packaged in several downstream channels: + +* `PyPI `__ — the primary + distribution, updated by the maintainer at release time (see + :ref:`release-process`). +* `conda-forge `__ — + the conda-forge bot opens a PR on the feedstock automatically when a + new PyPI release appears. +* `Spack `__ + — the recipe lives in the + `spack-packages `__ repository + at ``repos/spack_repo/builtin/packages/py_quantities/package.py``. + Updates are made by opening a PR against ``spack/spack-packages`` after + a release. + +For other downstream packagers we rely on the packagers' own +update workflows. `Repology +`__ shows a +useful cross-distribution view of which version each packager is +currently shipping; please open an issue if you find a downstream +package that has fallen significantly behind. diff --git a/doc/devel/onboarding.rst b/doc/devel/onboarding.rst new file mode 100644 index 0000000..ef01d91 --- /dev/null +++ b/doc/devel/onboarding.rst @@ -0,0 +1,146 @@ +.. _onboarding: + +*********** +Onboarding +*********** + +This page is the starting point for new contributors. It walks through +setting up a development environment, running the tests, getting a high-level +sense of the codebase, and submitting a first pull request. + +For deeper material, see also: + +* :ref:`architecture-overview` — internal design of the package. +* :ref:`development-workflow` — branching, review, versioning and deprecation + policy. +* :ref:`maintenance` — routine maintenance tasks for new committers. + + +Setting up a development environment +==================================== + +Fork the repository on GitHub (use the *Fork* button at the top of +https://github.com/python-quantities/python-quantities), then clone your +fork:: + + git clone git@github.com:/python-quantities.git + cd python-quantities + +It is also convenient to add the upstream repository as a remote, so you +can keep your fork in sync:: + + git remote add upstream https://github.com/python-quantities/python-quantities.git + +Create a virtual environment with a supported Python (see pyproject.toml). For +example, using the standard library ``venv`` module:: + + python -m venv env + source env/bin/activate + +Install *quantities* in editable mode together with the test and +documentation extras:: + + pip install -e ".[test,doc]" + +The ``test`` extra pulls in `pytest`_ and `Ruff`_; ``doc`` pulls in +`Sphinx`_. + +.. _pytest: https://docs.pytest.org/ +.. _Sphinx: https://www.sphinx-doc.org/ +.. _Ruff: https://docs.astral.sh/ruff/ + + +Running the tests +================= + +The full test suite is run with ``pytest`` from the repository root:: + + pytest + +To run just one test module, or one test:: + + pytest quantities/tests/test_arithmetic.py + pytest quantities/tests/test_arithmetic.py::TestQuantityArithmetic::test_add + +The doctests in ``README.rst`` are also part of the suite that runs in CI:: + + python -m doctest README.rst + +If you have multiple NumPy versions available you can install a specific +version to verify behaviour against it. + + +Code map +======== + +A brief tour of the source tree (see :ref:`architecture-overview` for the +design rationale): + +``quantities/quantity.py`` + Defines :class:`Quantity`, the main user-facing array type. Subclasses + :class:`numpy.ndarray` and carries a ``_dimensionality`` attribute. + +``quantities/unitquantity.py`` + Defines :class:`UnitQuantity` (a scalar :class:`Quantity` of magnitude 1 + representing a named unit) and its SI-base-dimension subclasses + (:class:`UnitLength`, :class:`UnitMass`, :class:`UnitTime`, …). + +``quantities/dimensionality.py`` + Implements :class:`Dimensionality`, a ``dict`` subclass that maps + :class:`UnitQuantity` objects to integer exponents. + +``quantities/registry.py`` + The ``unit_registry`` singleton and the safe-AST parser used to evaluate + unit-expression strings such as ``"kg*m/s**2"`` without arbitrary code + execution. + +``quantities/umath.py`` + Wrappers around NumPy ufuncs (``sin``, ``cumsum``, ``gradient``, + ``trapz``, …) that handle dimensional analysis correctly. + +``quantities/uncertainquantity.py`` + :class:`UncertainQuantity`, a :class:`Quantity` subclass that propagates + a ``_uncertainty`` attribute through arithmetic. + +``quantities/units/`` + One module per physical dimension (length, mass, time, …). Each + module defines :class:`UnitQuantity` instances that self-register on + creation. + +``quantities/constants/`` + Physical constants from the NIST CODATA dataset (``NIST_codata.txt``); + the generated ``_codata.py`` module is rebuilt by ``python setup.py + data``. + +``quantities/tests/`` + The pytest suite. + + +Submitting a first pull request +=============================== + +1. Fork the repository on GitHub and clone your fork. +2. Create a topic branch off ``master``:: + + git checkout -b my-fix + +3. Make your changes. Please add or update tests so that the change is + covered, and follow the existing docstring style (NumPy convention — see + :ref:`documenting-quantities`). +4. Run the test suite (``pytest``) and the linter + (``ruff check quantities``) locally. +5. Commit, push to your fork, and open a pull request against + ``python-quantities/python-quantities:master``. + +A maintainer will review the PR. Once CI passes and the review is approved, +the change will be merged. + +For details on the review process, versioning and the deprecation policy, +see :ref:`development-workflow`. + + +Asking for help +=============== + +If anything in this guide is unclear, or you get stuck, please open a +GitHub issue or comment on a relevant existing one. diff --git a/doc/devel/release.rst b/doc/devel/release.rst index f434043..1bc400e 100644 --- a/doc/devel/release.rst +++ b/doc/devel/release.rst @@ -1,3 +1,5 @@ +.. _release-process: + ******** Releases ******** diff --git a/doc/devel/workflow.rst b/doc/devel/workflow.rst new file mode 100644 index 0000000..894dabb --- /dev/null +++ b/doc/devel/workflow.rst @@ -0,0 +1,113 @@ +.. _development-workflow: + +******************** +Development Workflow +******************** + +This page documents the integration workflow, code-style policy, versioning +scheme, and deprecation policy used by *quantities*. + + +Branching and integration +========================= + +The ``master`` branch is the integration branch. All development happens in +topic branches on each contributor's personal fork of the repository +(including for maintainers) and is merged into ``master`` via pull request. +Branches are not pushed directly to the upstream repository. + +Pull requests should: + +* be based on an up-to-date copy of ``master``, +* contain tests covering any behavioural change, +* keep unrelated changes out (one logical change per PR). + +Trivial single-commit PRs are fine; longer changes should be broken into +logically coherent commits. + + +Code review +=========== + +Every pull request must be reviewed by at least one maintainer before being +merged. CI (GitHub Actions) must pass, including: + +* the ``pytest`` matrix across the supported Python and NumPy versions, +* the ``mypy`` type-check job, +* the ``ruff`` lint job (currently advisory — see below), +* the ``doctest`` of ``README.rst``. + +Reviewers look for: correctness, test coverage, dimensional-correctness of +new units or operations, and compatibility with the documented API. + + +Code style +========== + +The project follows :pep:`8`. The ``ruff`` linter is configured in +``pyproject.toml`` and is run in CI. While the existing codebase is brought +into full conformance the lint job is **advisory** (``continue-on-error: +true``); new code should nevertheless pass ``ruff check`` cleanly. + +Docstrings follow the NumPy documentation standard described in +:ref:`documenting-quantities`. + +To run the linter locally:: + + pip install -e ".[lint]" + ruff check quantities + + +.. _versioning-policy: + +Versioning +========== + +*quantities* follows `Semantic Versioning 2.0.0`_. Public API changes are +reflected in the version number as follows: + +* a **patch** bump (``0.16.3`` → ``0.16.4``) for bug fixes that do not + change the public API; +* a **minor** bump (``0.16.x`` → ``0.17.0``) for backwards-compatible + feature additions; +* a **major** bump (``0.x`` → ``1.0``) for backwards-incompatible changes, + once the project reaches a 1.0 release. + +The current major version is ``0``, so per SemVer §4 anything may change +between minor releases. In practice the project treats minor releases as +allowed to contain breaking changes only when accompanied by a documented +deprecation period (see below). + +Version numbers are derived from git tags by `setuptools_scm`_; tagged +releases of the form ``vX.Y.Z`` are pushed to GitHub and uploaded to PyPI +(see :ref:`release-process`). + +.. _Semantic Versioning 2.0.0: https://semver.org/spec/v2.0.0.html +.. _setuptools_scm: https://github.com/pypa/setuptools_scm/ + + +Deprecation policy +================== + +Public API elements that are scheduled for removal or change should be +deprecated before being removed: + +1. In the release in which the deprecation is decided: + + * emit a :class:`DeprecationWarning` (or a more specific subclass such + as :class:`FutureWarning`) from the deprecated entry point; + * mark the entry in the docstring with the Sphinx ``deprecated::`` + directive, naming the version in which the deprecation was introduced + and the replacement to use; + * record the deprecation in ``CHANGES.txt`` under the release notes for + that release. + +2. Keep the deprecated entry point working for **at least one minor + release** before it is removed. + +3. In the release that removes the deprecated entry point, record the + removal in ``CHANGES.txt``. + +For small, low-impact API tweaks where no caller is plausibly affected, the +deprecation step may be skipped at the maintainer's discretion; this should +still be called out in ``CHANGES.txt``. diff --git a/pyproject.toml b/pyproject.toml index 073e9da..4fe21e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dynamic = ["version"] [project.optional-dependencies] test = [ "pytest", - "wheel" + "wheel", + "ruff" ] doc = [ "sphinx" @@ -54,3 +55,21 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "quantities/_version.py" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +# pycodestyle errors and warnings, pyflakes, isort. +# Additional rule sets may be enabled over time as the codebase is +# brought into conformance. +select = ["E", "F", "W", "I"] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +# Unit modules deliberately re-export many names; suppress +# "imported but unused" warnings there. +"quantities/units/*.py" = ["F401"] +"quantities/constants/*.py" = ["F401"] +"quantities/__init__.py" = ["F401", "F403"]