Release v0.4.2
Some checks failed
audit / audit (push) Has been cancelled
docs / docs (push) Has been cancelled
lint / lint (push) Has been cancelled
test-coverage / test-coverage (push) Has been cancelled
test-docstrings / test-docstrings (push) Has been cancelled
tests / test-3.10 (push) Has been cancelled
tests / test-3.11 (push) Has been cancelled
tests / test-3.12 (push) Has been cancelled
tests / test-3.9 (push) Has been cancelled
Some checks failed
audit / audit (push) Has been cancelled
docs / docs (push) Has been cancelled
lint / lint (push) Has been cancelled
test-coverage / test-coverage (push) Has been cancelled
test-docstrings / test-docstrings (push) Has been cancelled
tests / test-3.10 (push) Has been cancelled
tests / test-3.11 (push) Has been cancelled
tests / test-3.12 (push) Has been cancelled
tests / test-3.9 (push) Has been cancelled
This commit is contained in:
commit
351146dbe9
24 changed files with 3742 additions and 0 deletions
21
.github/workflows/audit.yml
vendored
Normal file
21
.github/workflows/audit.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: audit
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: audit
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
- run: nox -s audit
|
21
.github/workflows/docs.yml
vendored
Normal file
21
.github/workflows/docs.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: docs
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: docs
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
- run: nox -s docs
|
21
.github/workflows/lint.yml
vendored
Normal file
21
.github/workflows/lint.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: lint
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: lint
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
- run: nox -s lint
|
33
.github/workflows/release.yml
vendored
Normal file
33
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: release-to-pypi
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# Make all of the below versions available simultaneously
|
||||||
|
python-version: |
|
||||||
|
3.9
|
||||||
|
3.10
|
||||||
|
3.11
|
||||||
|
3.12
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
# Run some CI tasks before to ensure code/docs are still good;
|
||||||
|
# the "test" session is run once for every Pyhthon version above
|
||||||
|
- run: nox -s audit docs lint test test-docstrings
|
||||||
|
|
||||||
|
- run: poetry build
|
||||||
|
- run: poetry publish --username=__token__ --password=${{ secrets.PYPI_TOKEN }}
|
31
.github/workflows/test_coverage.yml
vendored
Normal file
31
.github/workflows/test_coverage.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
name: test-coverage
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test-coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: test-coverage
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# Make all of the below versions available simultaneously
|
||||||
|
python-version: |
|
||||||
|
3.9
|
||||||
|
3.10
|
||||||
|
3.11
|
||||||
|
3.12
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
# "test-coverage" triggers further nox sessions,
|
||||||
|
# one for each of the above Python versions,
|
||||||
|
# before all results are collected together
|
||||||
|
- run: nox -s test-coverage
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
21
.github/workflows/test_docstrings.yml
vendored
Normal file
21
.github/workflows/test_docstrings.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: test-docstrings
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test-docstrings:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: test-docstrings
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
- run: nox -s test-docstrings
|
24
.github/workflows/tests.yml
vendored
Normal file
24
.github/workflows/tests.yml
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
name: tests
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
|
name: test-${{ matrix.python-version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- run: python --version
|
||||||
|
- run: pip --version
|
||||||
|
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
- run: pip install nox==2024.4.15
|
||||||
|
- run: pip install poetry==1.8.3
|
||||||
|
|
||||||
|
- run: nox -s test-${{ matrix.python-version }}
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.cache/
|
||||||
|
dist/
|
||||||
|
poetry.toml
|
||||||
|
**/__pycache__/
|
||||||
|
.venv/
|
48
.pre-commit-config.yaml
Normal file
48
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
default_stages:
|
||||||
|
- commit
|
||||||
|
fail_fast: true
|
||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: local-lint
|
||||||
|
name: Lint the source files
|
||||||
|
entry: nox -s lint --
|
||||||
|
language: system
|
||||||
|
stages:
|
||||||
|
- commit
|
||||||
|
types:
|
||||||
|
- python
|
||||||
|
verbose: true
|
||||||
|
- id: local-test
|
||||||
|
name: Run the entire test suite
|
||||||
|
entry: nox -s _pre-commit-test-hook --
|
||||||
|
language: system
|
||||||
|
stages:
|
||||||
|
- merge-commit
|
||||||
|
types:
|
||||||
|
- text
|
||||||
|
verbose: true
|
||||||
|
- repo: "https://github.com/pre-commit/pre-commit-hooks"
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: check-added-large-files
|
||||||
|
args:
|
||||||
|
- "--maxkb=100"
|
||||||
|
- id: check-builtin-literals
|
||||||
|
- id: check-case-conflict
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-yaml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
stages:
|
||||||
|
- commit
|
||||||
|
- id: mixed-line-ending
|
||||||
|
args:
|
||||||
|
- "--fix=no"
|
||||||
|
- id: no-commit-to-branch
|
||||||
|
args:
|
||||||
|
- "--branch"
|
||||||
|
- main
|
||||||
|
- id: trailing-whitespace
|
||||||
|
stages:
|
||||||
|
- commit
|
17
.readthedocs.yml
Normal file
17
.readthedocs.yml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
build:
|
||||||
|
os: ubuntu-24.04
|
||||||
|
tools:
|
||||||
|
python: "3.12"
|
||||||
|
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/conf.py
|
||||||
|
fail_on_warning: true
|
||||||
|
|
||||||
|
formats: all
|
||||||
|
|
||||||
|
python:
|
||||||
|
install:
|
||||||
|
- requirements: docs/requirements.txt
|
||||||
|
- path: .
|
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Alexander Hess [alexander@webartifex.biz]
|
||||||
|
|
||||||
|
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.
|
170
README.md
Normal file
170
README.md
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
# A Python library to study linear algebra
|
||||||
|
|
||||||
|
The goal of the `lalib` project is to create
|
||||||
|
a library written in pure [Python](https://docs.python.org/3/)
|
||||||
|
(incl. the [standard library](https://docs.python.org/3/library/index.html))
|
||||||
|
and thereby learn about
|
||||||
|
[linear algebra](https://en.wikipedia.org/wiki/Linear_algebra)
|
||||||
|
by reading and writing code.
|
||||||
|
|
||||||
|
|
||||||
|
[![PyPI: Package version](https://img.shields.io/pypi/v/lalib?color=blue)](https://pypi.org/project/lalib/)
|
||||||
|
[![PyPI: Supported Python versions](https://img.shields.io/pypi/pyversions/lalib)](https://pypi.org/project/lalib/)
|
||||||
|
[![PyPI: Number of monthly downloads](https://img.shields.io/pypi/dm/lalib)](https://pypistats.org/packages/lalib)
|
||||||
|
|
||||||
|
[![Documentation: Status](https://readthedocs.org/projects/lalib/badge/?version=latest)](https://lalib.readthedocs.io/en/latest/?badge=latest)
|
||||||
|
[![Test suite: Status](https://github.com/webartifex/lalib/actions/workflows/tests.yml/badge.svg)](https://github.com/webartifex/lalib/actions/workflows/tests.yml)
|
||||||
|
[![Test coverage: codecov](https://codecov.io/github/webartifex/lalib/graph/badge.svg?token=J4LWOMVP0R)](https://codecov.io/github/webartifex/lalib)
|
||||||
|
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
|
||||||
|
[![Type checking: mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
|
||||||
|
[![Code linting: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
This project is published on [PyPI](https://pypi.org/project/lalib/).
|
||||||
|
To install it, open any Python prompt and type:
|
||||||
|
|
||||||
|
`pip install lalib`
|
||||||
|
|
||||||
|
You may want to do so
|
||||||
|
within a [virtual environment](https://docs.python.org/3/library/venv.html)
|
||||||
|
or a [Jupyter notebook](https://docs.jupyter.org/en/latest/#what-is-a-notebook).
|
||||||
|
|
||||||
|
|
||||||
|
## Contributing & Development
|
||||||
|
|
||||||
|
This project is open for any kind of contribution,
|
||||||
|
be it by writing code for new features or bugfixes,
|
||||||
|
or by raising [issues](https://github.com/webartifex/lalib/issues).
|
||||||
|
All contributions become open-source themselves, under the
|
||||||
|
[MIT license](https://github.com/webartifex/lalib/blob/main/LICENSE.txt).
|
||||||
|
|
||||||
|
|
||||||
|
### Local Develop Environment
|
||||||
|
|
||||||
|
In order to play with the `lalib` codebase,
|
||||||
|
you need to set up a develop environment on your own computer.
|
||||||
|
|
||||||
|
First, get your own copy of this repository:
|
||||||
|
|
||||||
|
`git clone git@github.com:webartifex/lalib.git`
|
||||||
|
|
||||||
|
While `lalib` comes without any dependencies
|
||||||
|
except core Python and the standard library for the user,
|
||||||
|
we assume a couple of packages and tools be installed
|
||||||
|
to ensure code quality during development.
|
||||||
|
These can be viewed in the
|
||||||
|
[pyproject.toml](https://github.com/webartifex/lalib/blob/main/pyproject.toml) file
|
||||||
|
and are managed with [poetry](https://python-poetry.org/docs/)
|
||||||
|
which needs to be installed as well.
|
||||||
|
`poetry` also creates and manages a
|
||||||
|
[virtual environment](https://docs.python.org/3/tutorial/venv.html)
|
||||||
|
with the develop tools,
|
||||||
|
and pins their exact installation versions in the
|
||||||
|
[poetry.lock](https://github.com/webartifex/lalib/blob/main/poetry.lock) file.
|
||||||
|
|
||||||
|
To replicate the project maintainer's develop environment, run:
|
||||||
|
|
||||||
|
`poetry install`
|
||||||
|
|
||||||
|
|
||||||
|
### Maintenance Tasks
|
||||||
|
|
||||||
|
We use [nox](https://nox.thea.codes/en/stable/) to run
|
||||||
|
the test suite and other maintenance tasks during development
|
||||||
|
in isolated environments.
|
||||||
|
`nox` is similar to the popular [tox](https://tox.readthedocs.io/en/latest/).
|
||||||
|
It is configured in the
|
||||||
|
[noxfile.py](https://github.com/webartifex/lalib/blob/main/noxfile.py) file.
|
||||||
|
`nox` is assumed to be installed as well
|
||||||
|
and is therefore not a project dependency.
|
||||||
|
|
||||||
|
To list all available tasks, called sessions in `nox`, simply run:
|
||||||
|
|
||||||
|
`nox --list` or `nox -l` for short
|
||||||
|
|
||||||
|
To execute all default tasks, simply invoke:
|
||||||
|
|
||||||
|
`nox`
|
||||||
|
|
||||||
|
This includes running the test suite for the project's main Python version
|
||||||
|
(i.e., [3.12](https://devguide.python.org/versions/)).
|
||||||
|
|
||||||
|
|
||||||
|
#### Code Formatting & Linting
|
||||||
|
|
||||||
|
We follow [Google's Python style guide](https://google.github.io/styleguide/pyguide.html)
|
||||||
|
and include [type hints](https://docs.python.org/3/library/typing.html)
|
||||||
|
where possible.
|
||||||
|
|
||||||
|
During development,
|
||||||
|
`nox -s format` and `nox -s lint` may be helpful.
|
||||||
|
Both can be speed up by re-using a previously created environment
|
||||||
|
with the `-R` flag.
|
||||||
|
|
||||||
|
The first task formats all source code files with
|
||||||
|
[autoflake](https://pypi.org/project/autoflake/),
|
||||||
|
[black](https://pypi.org/project/black/), and
|
||||||
|
[isort](https://pypi.org/project/isort/).
|
||||||
|
|
||||||
|
The second task lints all source code files with
|
||||||
|
[flake8](https://pypi.org/project/flake8/),
|
||||||
|
[mypy](https://pypi.org/project/mypy/), and
|
||||||
|
[ruff](https://pypi.org/project/ruff/).
|
||||||
|
`flake8` is configured with a couple of plug-ins.
|
||||||
|
|
||||||
|
You may want to install the [pre-commit](https://pre-commit.com/) hooks
|
||||||
|
that come with the project:
|
||||||
|
|
||||||
|
`nox -s pre-commit-install`
|
||||||
|
|
||||||
|
Then, the linting and testing occurs automatically before every commit.
|
||||||
|
|
||||||
|
|
||||||
|
#### Test Suite
|
||||||
|
|
||||||
|
We use [pytest](https://docs.pytest.org/en/stable/)
|
||||||
|
to obtain confidence in the correctness of `lalib`.
|
||||||
|
To run the tests
|
||||||
|
for *all* supported Python versions
|
||||||
|
in isolated (and perfectly reproducable) environments,
|
||||||
|
invoke:
|
||||||
|
|
||||||
|
`nox -s test`
|
||||||
|
|
||||||
|
|
||||||
|
### Branching Strategy
|
||||||
|
|
||||||
|
The branches in this repository follow the
|
||||||
|
[GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) model.
|
||||||
|
Feature branches are rebased onto
|
||||||
|
the [develop](https://github.com/webartifex/lalib/tree/develop) branch
|
||||||
|
*before* being merged.
|
||||||
|
Whereas a rebase makes a simple fast-forward merge possible,
|
||||||
|
all merges are made with explicit and *empty* merge commits.
|
||||||
|
This ensures that past branches remain visible in the logs,
|
||||||
|
for example, with `git log --graph`.
|
||||||
|
|
||||||
|
|
||||||
|
#### Versioning
|
||||||
|
|
||||||
|
The version identifiers adhere to a subset of the rules in
|
||||||
|
[PEP440](https://peps.python.org/pep-0440/) and
|
||||||
|
follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
So, releases to [PyPI](https://pypi.org/project/lalib/#history)
|
||||||
|
come in the popular `major.minor.patch` format.
|
||||||
|
The specific rules for this project are explained
|
||||||
|
[here](https://github.com/webartifex/lalib/blob/main/tests/test_version.py).
|
||||||
|
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
|
||||||
|
### v0.4.2, 2024-09-10
|
||||||
|
|
||||||
|
- This release provides no functionality
|
||||||
|
- Its purpose is to (re-)claim the
|
||||||
|
[lalib](https://pypi.org/project/lalib/) name on PyPI
|
||||||
|
- We can *not* start with **v0.1.0**
|
||||||
|
because we already used this when learning to use PyPI years back
|
15
docs/conf.py
Normal file
15
docs/conf.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
"""Configure sphinx."""
|
||||||
|
|
||||||
|
import lalib
|
||||||
|
|
||||||
|
|
||||||
|
project = lalib.__pkg_name__
|
||||||
|
author = lalib.__author__
|
||||||
|
project_copyright = f"2024, {author}"
|
||||||
|
version = release = lalib.__version__
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.napoleon",
|
||||||
|
"sphinx_autodoc_typehints",
|
||||||
|
]
|
41
docs/index.rst
Normal file
41
docs/index.rst
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
lalib - A Python library to study linear algebra
|
||||||
|
================================================
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:hidden:
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
license
|
||||||
|
reference
|
||||||
|
|
||||||
|
|
||||||
|
The goal of this package is to provide
|
||||||
|
a library written in pure `Python`_ (incl. the `standard library`_)
|
||||||
|
to learn about linear algebra by reading and writing code.
|
||||||
|
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Python 3.9 or newer is needed.
|
||||||
|
The package depends only on core Python (incl. the standard library).
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
`lalib`_ is available on `PyPI`_ via `pip`_:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ pip install lalib
|
||||||
|
|
||||||
|
It is recommended to install the package into a `virtual environment`_.
|
||||||
|
|
||||||
|
|
||||||
|
.. _standard library: https://docs.python.org/3/library/index.html
|
||||||
|
.. _lalib: https://github.com/webartifex/lalib
|
||||||
|
.. _pip: https://pip.pypa.io/en/stable/
|
||||||
|
.. _pypi: https://pypi.org/
|
||||||
|
.. _python: https://docs.python.org/3/
|
||||||
|
.. _virtual environment: https://docs.python.org/3/tutorial/venv.html
|
4
docs/license.rst
Normal file
4
docs/license.rst
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
License
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. include:: ../LICENSE.txt
|
6
docs/reference.rst
Normal file
6
docs/reference.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
Reference
|
||||||
|
=========
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
:backlinks: none
|
3
docs/requirements.txt
Normal file
3
docs/requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# The following pinned dependencies must be updated manually
|
||||||
|
sphinx==8.0.2
|
||||||
|
sphinx-autodoc-typehints==2.3.0
|
507
noxfile.py
Normal file
507
noxfile.py
Normal file
|
@ -0,0 +1,507 @@
|
||||||
|
"""Maintenance tasks run in isolated environments."""
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Generator, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import nox
|
||||||
|
from packaging import version as pkg_version
|
||||||
|
|
||||||
|
|
||||||
|
def nested_defaultdict() -> collections.defaultdict[str, Any]:
|
||||||
|
"""Create a multi-level `defaultdict` with variable depth.
|
||||||
|
|
||||||
|
The returned `dict`ionary never raises a `KeyError`
|
||||||
|
but always returns an empty `dict`ionary instead.
|
||||||
|
This behavior is occurs recursively.
|
||||||
|
|
||||||
|
Adjusted from: https://stackoverflow.com/a/8702435
|
||||||
|
"""
|
||||||
|
return collections.defaultdict(nested_defaultdict)
|
||||||
|
|
||||||
|
|
||||||
|
def defaultify(obj: Any) -> Any:
|
||||||
|
"""Turn nested `dict`s into nested `defaultdict`s."""
|
||||||
|
if isinstance(obj, Mapping):
|
||||||
|
return collections.defaultdict(
|
||||||
|
nested_defaultdict,
|
||||||
|
{key: defaultify(val) for key, val in obj.items()},
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def load_pyproject_toml() -> collections.defaultdict[str, Any]:
|
||||||
|
"""Load the contents of the pyproject.toml file.
|
||||||
|
|
||||||
|
The contents are represented as a `nested_defaultdict`;
|
||||||
|
so, missing keys and tables (i.e., "sections" in the .ini format)
|
||||||
|
do not result in `KeyError`s but return empty `nested_defaultdict`s.
|
||||||
|
"""
|
||||||
|
return defaultify(nox.project.load_toml("pyproject.toml"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_supported_python_versions(*, reverse: bool = False) -> list[str]:
|
||||||
|
"""Parse the Python versions from the pyproject.toml file."""
|
||||||
|
pyproject = load_pyproject_toml()
|
||||||
|
version_names = {
|
||||||
|
classifier.rsplit(" ")[-1]
|
||||||
|
for classifier in pyproject["tool"]["poetry"]["classifiers"]
|
||||||
|
if classifier.startswith("Programming Language :: Python :: ")
|
||||||
|
}
|
||||||
|
return sorted(version_names, key=pkg_version.Version, reverse=reverse)
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_PYTHONS = load_supported_python_versions(reverse=True)
|
||||||
|
MAIN_PYTHON = "3.12"
|
||||||
|
|
||||||
|
DOCS_SRC, DOCS_BUILD = ("docs/", ".cache/docs/")
|
||||||
|
TESTS_LOCATION = "tests/"
|
||||||
|
SRC_LOCATIONS = ("./noxfile.py", "src/", DOCS_SRC, TESTS_LOCATION)
|
||||||
|
|
||||||
|
|
||||||
|
nox.options.envdir = ".cache/nox"
|
||||||
|
nox.options.error_on_external_run = True # only `git` and `poetry` are external
|
||||||
|
nox.options.reuse_venv = "no"
|
||||||
|
nox.options.sessions = ( # run by default when invoking `nox` on the CLI
|
||||||
|
"format",
|
||||||
|
"lint",
|
||||||
|
"audit",
|
||||||
|
"docs",
|
||||||
|
"test-docstrings",
|
||||||
|
f"test-{MAIN_PYTHON}",
|
||||||
|
)
|
||||||
|
nox.options.stop_on_first_error = True
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="audit", python=MAIN_PYTHON, reuse_venv=False)
|
||||||
|
def audit_pinned_dependencies(session: nox.Session) -> None:
|
||||||
|
"""Check dependencies for vulnerabilities with `pip-audit`.
|
||||||
|
|
||||||
|
The dependencies are those defined in the "poetry.lock" file.
|
||||||
|
|
||||||
|
`pip-audit` uses the Python Packaging Advisory Database
|
||||||
|
(Source: https://github.com/pypa/advisory-database).
|
||||||
|
"""
|
||||||
|
do_not_reuse(session)
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
install_unpinned(session, "pip-audit")
|
||||||
|
|
||||||
|
session.run("pip-audit", "--version")
|
||||||
|
suppress_poetry_export_warning(session)
|
||||||
|
with tempfile.NamedTemporaryFile() as requirements_txt:
|
||||||
|
session.run(
|
||||||
|
"poetry",
|
||||||
|
"export",
|
||||||
|
"--format=requirements.txt",
|
||||||
|
f"--output={requirements_txt.name}",
|
||||||
|
"--with=dev",
|
||||||
|
external=True,
|
||||||
|
)
|
||||||
|
session.run(
|
||||||
|
"pip-audit",
|
||||||
|
f"--requirement={requirements_txt.name}",
|
||||||
|
"--local",
|
||||||
|
"--progress-spinner=off",
|
||||||
|
"--strict",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="audit-updates", python=MAIN_PYTHON, reuse_venv=False)
|
||||||
|
def audit_unpinned_dependencies(session: nox.Session) -> None:
|
||||||
|
"""Check updates for dependencies with `pip-audit`.
|
||||||
|
|
||||||
|
Convenience task to check dependencies before updating
|
||||||
|
them in the "poetry.lock" file.
|
||||||
|
|
||||||
|
Uses `pip` to resolve the dependencies declared in the
|
||||||
|
"pyproject.toml" file (incl. the "dev" group) to their
|
||||||
|
latest PyPI version.
|
||||||
|
"""
|
||||||
|
do_not_reuse(session)
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
pyproject = load_pyproject_toml()
|
||||||
|
poetry_config = pyproject["tool"]["poetry"]
|
||||||
|
|
||||||
|
dependencies = {
|
||||||
|
*(poetry_config["dependencies"].keys()),
|
||||||
|
*(poetry_config["group"]["dev"]["dependencies"].keys()),
|
||||||
|
}
|
||||||
|
dependencies.discard("python") # Python itself cannot be installed f>
|
||||||
|
|
||||||
|
install_unpinned(session, "pip-audit", *sorted(dependencies))
|
||||||
|
session.run("pip-audit", "--version")
|
||||||
|
session.run(
|
||||||
|
"pip-audit",
|
||||||
|
"--local",
|
||||||
|
"--progress-spinner=off",
|
||||||
|
"--strict",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(python=MAIN_PYTHON)
|
||||||
|
def docs(session: nox.Session) -> None:
|
||||||
|
"""Build the documentation with `sphinx`."""
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
# The documentation tools require the developed package as
|
||||||
|
# otherwise sphinx's autodoc could not include the docstrings
|
||||||
|
session.debug("Install only the `lalib` package and the documentation tools")
|
||||||
|
install_unpinned(session, "-e", ".") # editable to be able to reuse the session
|
||||||
|
install_pinned(session, "sphinx", "sphinx-autodoc-typehints")
|
||||||
|
|
||||||
|
session.run("sphinx-build", "--builder=html", DOCS_SRC, DOCS_BUILD)
|
||||||
|
session.run("sphinx-build", "--builder=linkcheck", DOCS_SRC, DOCS_BUILD) # > 200 OK
|
||||||
|
|
||||||
|
session.log(f"Docs are available at {DOCS_BUILD}index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="format", python=MAIN_PYTHON)
|
||||||
|
def format_(session: nox.Session) -> None:
|
||||||
|
"""Format source files with `autoflake`, `black`, and `isort`."""
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
install_pinned(session, "autoflake", "black", "isort", "ruff")
|
||||||
|
|
||||||
|
locations = session.posargs or SRC_LOCATIONS
|
||||||
|
|
||||||
|
session.run("autoflake", "--version")
|
||||||
|
session.run("autoflake", *locations)
|
||||||
|
|
||||||
|
session.run("black", "--version")
|
||||||
|
session.run("black", *locations)
|
||||||
|
|
||||||
|
session.run("isort", "--version-number")
|
||||||
|
session.run("isort", *locations)
|
||||||
|
|
||||||
|
session.run("ruff", "--version")
|
||||||
|
session.run("ruff", "check", "--fix-only", *locations)
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(python=MAIN_PYTHON)
|
||||||
|
def lint(session: nox.Session) -> None:
|
||||||
|
"""Lint source files with `flake8`, `mypy`, and `ruff`."""
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
install_pinned(
|
||||||
|
session,
|
||||||
|
"flake8",
|
||||||
|
"flake8-annotations",
|
||||||
|
"flake8-bandit",
|
||||||
|
"flake8-black",
|
||||||
|
"flake8-broken-line",
|
||||||
|
"flake8-bugbear",
|
||||||
|
"flake8-commas",
|
||||||
|
"flake8-comprehensions",
|
||||||
|
"flake8-debugger",
|
||||||
|
"flake8-docstrings",
|
||||||
|
"flake8-eradicate",
|
||||||
|
"flake8-isort",
|
||||||
|
"flake8-quotes",
|
||||||
|
"flake8-string-format",
|
||||||
|
"flake8-pyproject",
|
||||||
|
"flake8-pytest-style",
|
||||||
|
"mypy",
|
||||||
|
"pep8-naming", # flake8 plug-in
|
||||||
|
"pydoclint[flake8]",
|
||||||
|
"ruff",
|
||||||
|
)
|
||||||
|
|
||||||
|
locations = session.posargs or SRC_LOCATIONS
|
||||||
|
|
||||||
|
session.run("flake8", "--version")
|
||||||
|
session.run("flake8", *locations)
|
||||||
|
|
||||||
|
session.run("mypy", "--version")
|
||||||
|
session.run("mypy", *locations)
|
||||||
|
|
||||||
|
session.run("ruff", "--version")
|
||||||
|
session.run("ruff", "check", *locations)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_DEPENDENCIES = (
|
||||||
|
"packaging",
|
||||||
|
"pytest",
|
||||||
|
"pytest-cov",
|
||||||
|
"semver",
|
||||||
|
"xdoctest",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(python=SUPPORTED_PYTHONS)
|
||||||
|
def test(session: nox.Session) -> None:
|
||||||
|
"""Test code with `pytest`."""
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
install_unpinned(session, "-e", ".") # "-e" makes session reuseable
|
||||||
|
install_pinned(session, *TEST_DEPENDENCIES)
|
||||||
|
|
||||||
|
# If this function is run by the `pre-commit` framework, extra
|
||||||
|
# arguments are dropped by the hack inside `pre_commit_test_hook()`
|
||||||
|
posargs = () if session.env.get("_drop_posargs") else session.posargs
|
||||||
|
args = posargs or (
|
||||||
|
"--cov",
|
||||||
|
"--no-cov-on-fail",
|
||||||
|
TESTS_LOCATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.run("pytest", *args)
|
||||||
|
|
||||||
|
|
||||||
|
_magic_number = random.randint(0, 987654321) # noqa: S311
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="test-coverage", python=MAIN_PYTHON, reuse_venv=True)
|
||||||
|
def test_coverage(session: nox.Session) -> None:
|
||||||
|
"""Report the combined coverage statistics.
|
||||||
|
|
||||||
|
Run the test suite for all supported Python versions
|
||||||
|
and combine the coverage statistics.
|
||||||
|
"""
|
||||||
|
install_pinned(session, "coverage")
|
||||||
|
|
||||||
|
session.run("python", "-m", "coverage", "erase")
|
||||||
|
|
||||||
|
for version in SUPPORTED_PYTHONS:
|
||||||
|
session.notify(f"_test-coverage-run-{version}", (_magic_number,))
|
||||||
|
session.notify("_test-coverage-report", (_magic_number,))
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="_test-coverage-run", python=SUPPORTED_PYTHONS, reuse_venv=False)
|
||||||
|
def test_coverage_run(session: nox.Session) -> None:
|
||||||
|
"""Measure the test coverage."""
|
||||||
|
do_not_reuse(session)
|
||||||
|
do_not_run_directly(session)
|
||||||
|
|
||||||
|
start(session)
|
||||||
|
|
||||||
|
session.install(".")
|
||||||
|
install_pinned(session, "coverage", *TEST_DEPENDENCIES)
|
||||||
|
|
||||||
|
session.run(
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"coverage",
|
||||||
|
"run",
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
TESTS_LOCATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="_test-coverage-report", python=MAIN_PYTHON, reuse_venv=True)
|
||||||
|
def test_coverage_report(session: nox.Session) -> None:
|
||||||
|
"""Report the combined coverage statistics."""
|
||||||
|
do_not_run_directly(session)
|
||||||
|
|
||||||
|
install_pinned(session, "coverage")
|
||||||
|
|
||||||
|
session.run("python", "-m", "coverage", "combine")
|
||||||
|
|
||||||
|
if codecov_token := os.environ.get("CODECOV_TOKEN"):
|
||||||
|
install_unpinned(session, "codecov-cli")
|
||||||
|
session.run("python", "-m", "coverage", "xml", "--fail-under=0")
|
||||||
|
session.run(
|
||||||
|
"codecovcli",
|
||||||
|
"upload-process",
|
||||||
|
"--fail-on-error",
|
||||||
|
"--file=.cache/coverage/report.xml",
|
||||||
|
f"--token={codecov_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
session.run("python", "-m", "coverage", "report", "--fail-under=100")
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="test-docstrings", python=MAIN_PYTHON)
|
||||||
|
def test_docstrings(session: nox.Session) -> None:
|
||||||
|
"""Test docstrings with `xdoctest`."""
|
||||||
|
start(session)
|
||||||
|
install_pinned(session, "xdoctest[colors]")
|
||||||
|
|
||||||
|
session.run("xdoctest", "--version")
|
||||||
|
session.run("xdoctest", "src/lalib")
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="clean-cwd", python=MAIN_PYTHON, venv_backend="none")
|
||||||
|
def clean_cwd(session: nox.Session) -> None:
|
||||||
|
"""Remove (almost) all glob patterns listed in git's ignore file.
|
||||||
|
|
||||||
|
Compared to `git clean -X` do not remove pyenv's
|
||||||
|
".python-version" file and poetry's virtual environment.
|
||||||
|
"""
|
||||||
|
do_not_remove = (".python-version", ".venv")
|
||||||
|
# Paths are resolved into absolute ones to avoid accidental matches
|
||||||
|
excluded_paths = {pathlib.Path(path).resolve() for path in do_not_remove}
|
||||||
|
|
||||||
|
with pathlib.Path(".gitignore").open() as fp:
|
||||||
|
ignored_patterns = [pattern for pattern in fp if not pattern.startswith("#")]
|
||||||
|
|
||||||
|
for path in _expand(*ignored_patterns):
|
||||||
|
# The `path` must not be a sub-path of an `excluded_path`
|
||||||
|
if {path, *path.parents} & excluded_paths:
|
||||||
|
continue
|
||||||
|
|
||||||
|
session.run("rm", "-rf", path)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand(*patterns: str) -> Generator[pathlib.Path, None, None]:
|
||||||
|
"""Expand glob patterns into (resolved) paths.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*patterns: patterns to be expanded
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
expanded: an expanded path
|
||||||
|
"""
|
||||||
|
for pattern in patterns:
|
||||||
|
expanded_paths = pathlib.Path.cwd().glob(pattern.strip())
|
||||||
|
for path in expanded_paths:
|
||||||
|
yield path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="pre-commit-install", python=MAIN_PYTHON, venv_backend="none")
|
||||||
|
def pre_commit_install(session: nox.Session) -> None:
|
||||||
|
"""Install `pre-commit` hooks."""
|
||||||
|
for type_ in ("pre-commit", "pre-merge-commit"):
|
||||||
|
session.run(
|
||||||
|
"poetry",
|
||||||
|
"run",
|
||||||
|
"pre-commit",
|
||||||
|
"install",
|
||||||
|
f"--hook-type={type_}",
|
||||||
|
external=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(name="_pre-commit-test-hook", python=MAIN_PYTHON, reuse_venv=False)
|
||||||
|
def pre_commit_test_hook(session: nox.Session) -> None:
|
||||||
|
"""`pre-commit` hook to run all tests before merges.
|
||||||
|
|
||||||
|
Ignores the paths to the staged files passed in by the
|
||||||
|
`pre-commit` framework and executes all tests instead. So,
|
||||||
|
`nox -s _pre-commit-test-hook -- FILE1, ...` drops the "FILE1, ...".
|
||||||
|
"""
|
||||||
|
do_not_reuse(session)
|
||||||
|
|
||||||
|
# Little Hack: Create a flag in the env(ironment) ...
|
||||||
|
session.env["_drop_posargs"] = "true"
|
||||||
|
|
||||||
|
# ... and call `test()` directly because `session.notify()`
|
||||||
|
# creates the "test" session as a new `nox.Session` object
|
||||||
|
# that does not have the flag set
|
||||||
|
test(session)
|
||||||
|
|
||||||
|
|
||||||
|
def do_not_reuse(session: nox.Session, *, raise_error: bool = True) -> None:
|
||||||
|
"""Do not reuse a session with the "-r" flag."""
|
||||||
|
if session._runner.venv._reused: # noqa:SLF001
|
||||||
|
if raise_error:
|
||||||
|
session.error('The session must be run without the "-r" flag')
|
||||||
|
else:
|
||||||
|
session.warn('The session must be run without the "-r" flag')
|
||||||
|
|
||||||
|
|
||||||
|
def do_not_run_directly(session: nox.Session) -> None:
|
||||||
|
"""Do not run a session with `nox -s SESSION_NAME` directly."""
|
||||||
|
if not session.posargs or session.posargs[0] != _magic_number:
|
||||||
|
session.error("This session must not be run directly")
|
||||||
|
|
||||||
|
|
||||||
|
def start(session: nox.Session) -> None:
|
||||||
|
"""Show generic info about a session."""
|
||||||
|
if session.posargs:
|
||||||
|
session.debug(f"Received extra arguments: {session.posargs}")
|
||||||
|
|
||||||
|
session.debug("Some generic information about the environment")
|
||||||
|
session.run("python", "--version")
|
||||||
|
session.run("python", "-c", "import sys; print(sys.executable)")
|
||||||
|
session.run("python", "-c", "import sys; print(sys.path)")
|
||||||
|
session.run("python", "-c", "import os; print(os.getcwd())")
|
||||||
|
session.run("python", "-c", 'import os; print(os.environ["PATH"])')
|
||||||
|
|
||||||
|
session.env["BLACK_CACHE_DIR"] = ".cache/black"
|
||||||
|
session.env["PIP_CACHE_DIR"] = ".cache/pip"
|
||||||
|
session.env["PIP_DISABLE_PIP_VERSION_CHECK"] = "true"
|
||||||
|
|
||||||
|
|
||||||
|
def suppress_poetry_export_warning(session: nox.Session) -> None:
|
||||||
|
"""Temporary fix to avoid poetry's warning ...
|
||||||
|
|
||||||
|
... about "poetry-plugin-export not being installed in the future".
|
||||||
|
"""
|
||||||
|
session.run(
|
||||||
|
"poetry",
|
||||||
|
"config",
|
||||||
|
"--local",
|
||||||
|
"warnings.export",
|
||||||
|
"false",
|
||||||
|
external=True,
|
||||||
|
log=False, # because it's just a fix we don't want any message in the logs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_pinned(
|
||||||
|
session: nox.Session,
|
||||||
|
*packages_or_pip_args: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Install packages respecting the "poetry.lock" file.
|
||||||
|
|
||||||
|
Wraps `nox.sessions.Session.install()` such that it installs
|
||||||
|
packages respecting the pinned versions specified in poetry's
|
||||||
|
lock file. This makes nox sessions more deterministic.
|
||||||
|
"""
|
||||||
|
session.debug("Install packages respecting the poetry.lock file")
|
||||||
|
|
||||||
|
suppress_poetry_export_warning(session)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as requirements_txt:
|
||||||
|
session.run(
|
||||||
|
"poetry",
|
||||||
|
"export",
|
||||||
|
"--format=requirements.txt",
|
||||||
|
f"--output={requirements_txt.name}",
|
||||||
|
"--with=dev",
|
||||||
|
"--without-hashes",
|
||||||
|
external=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# `pip install --constraint ...` raises an error if the
|
||||||
|
# dependencies in requirements.txt contain "extras"
|
||||||
|
# => Strip "package[extras]==1.2.3" into "package==1.2.3"
|
||||||
|
dependencies = pathlib.Path(requirements_txt.name).read_text().split("\n")
|
||||||
|
dependencies = [re.sub(r"\[.*\]==", "==", dep) for dep in dependencies]
|
||||||
|
pathlib.Path(requirements_txt.name).write_text("\n".join(dependencies))
|
||||||
|
|
||||||
|
session.install(
|
||||||
|
f"--constraint={requirements_txt.name}",
|
||||||
|
*packages_or_pip_args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_unpinned(
|
||||||
|
session: nox.Session,
|
||||||
|
*packages_or_pip_args: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Install the latest PyPI versions of packages."""
|
||||||
|
# Same logic to skip package installation as in core nox
|
||||||
|
# See: https://github.com/wntrblm/nox/blob/2024.04.15/nox/sessions.py#L775
|
||||||
|
venv = session._runner.venv # noqa: SLF001
|
||||||
|
if session._runner.global_config.no_install and venv._reused: # noqa: SLF001
|
||||||
|
return
|
||||||
|
|
||||||
|
session.install(*packages_or_pip_args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
if MAIN_PYTHON not in SUPPORTED_PYTHONS:
|
||||||
|
msg = f"MAIN_PYTHON version, v{MAIN_PYTHON}, is not in SUPPORTED_PYTHONS"
|
||||||
|
raise RuntimeError(msg)
|
1677
poetry.lock
generated
Normal file
1677
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
418
pyproject.toml
Normal file
418
pyproject.toml
Normal file
|
@ -0,0 +1,418 @@
|
||||||
|
[tool.poetry]
|
||||||
|
|
||||||
|
name = "lalib"
|
||||||
|
version = "0.4.2"
|
||||||
|
|
||||||
|
authors = [
|
||||||
|
"Alexander Hess <alexander@webartifex.biz>",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: Education",
|
||||||
|
"Intended Audience :: Science/Research",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
description = "A Python library to study linear algebra"
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
documentation = "https://lalib.readthedocs.io"
|
||||||
|
homepage = "https://github.com/webartifex/lalib"
|
||||||
|
repository = "https://github.com/webartifex/lalib"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
|
||||||
|
python = "^3.9"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
|
||||||
|
pre-commit = "^3.8"
|
||||||
|
|
||||||
|
# Code formatters
|
||||||
|
autoflake = "^2.3"
|
||||||
|
black = "^24.8"
|
||||||
|
isort = "^5.13"
|
||||||
|
|
||||||
|
# Code linters
|
||||||
|
flake8 = "^7.1"
|
||||||
|
flake8-annotations = "^3.1"
|
||||||
|
flake8-bandit = "^4.1"
|
||||||
|
flake8-black = "^0.3"
|
||||||
|
flake8-broken-line = "^1.0"
|
||||||
|
flake8-bugbear = "^24.8"
|
||||||
|
flake8-commas = "^4.0"
|
||||||
|
flake8-comprehensions = "^3.15"
|
||||||
|
flake8-debugger = "^4.1"
|
||||||
|
flake8-docstrings = "^1.7"
|
||||||
|
flake8-eradicate = "^1.5"
|
||||||
|
flake8-isort = "^6.1"
|
||||||
|
flake8-quotes = "^3.4"
|
||||||
|
flake8-string-format = "^0.3"
|
||||||
|
flake8-pyproject = "^1.2"
|
||||||
|
flake8-pytest-style = "^2.0"
|
||||||
|
mypy = "^1.11"
|
||||||
|
pep8-naming = "^0.14" # flake8 plug-in
|
||||||
|
pydoclint = { extras = ["flake8"], version = "^0.5" }
|
||||||
|
ruff = "^0.6"
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
sphinx = [
|
||||||
|
{ python = "=3.9", version = "^7.4" },
|
||||||
|
{ python = ">=3.10", version = "^8.0" },
|
||||||
|
]
|
||||||
|
sphinx-autodoc-typehints = "^2.3"
|
||||||
|
|
||||||
|
# Test suite
|
||||||
|
coverage = "^7.6"
|
||||||
|
packaging = "^24.1" # to test the version identifier
|
||||||
|
pytest = "^8.3"
|
||||||
|
pytest-cov = "^5.0"
|
||||||
|
semver = "^3.0" # to test the version identifier
|
||||||
|
tomli = [ { python = "<3.11", version = "^2.0" } ]
|
||||||
|
xdoctest = { extras = ["colors"], version = "^1.2" }
|
||||||
|
|
||||||
|
[tool.poetry.urls]
|
||||||
|
|
||||||
|
"Issues Tracker" = "https://github.com/webartifex/lalib/issues"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.autoflake]
|
||||||
|
# Source: https://github.com/PyCQA/autoflake#configuration
|
||||||
|
|
||||||
|
in-place = true
|
||||||
|
recursive = true
|
||||||
|
expand-star-imports = true
|
||||||
|
remove-all-unused-imports = true
|
||||||
|
ignore-init-module-imports = true # modifies "remove-all-unused-imports"
|
||||||
|
remove-duplicate-keys = true
|
||||||
|
remove-unused-variables = true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
# Source: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html
|
||||||
|
|
||||||
|
line-length = 88
|
||||||
|
target-version = ["py312", "py311", "py310", "py39"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.coverage]
|
||||||
|
# Source: https://coverage.readthedocs.io/en/latest/config.html
|
||||||
|
|
||||||
|
|
||||||
|
[tool.coverage.paths]
|
||||||
|
|
||||||
|
source = ["src/", "*/site-packages/"]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
|
skip_covered = true
|
||||||
|
skip_empty = true
|
||||||
|
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
|
||||||
|
data_file = ".cache/coverage/data"
|
||||||
|
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
|
||||||
|
source = ["lalib"]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.coverage.xml]
|
||||||
|
|
||||||
|
output = ".cache/coverage/report.xml"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.flake8]
|
||||||
|
|
||||||
|
select = [
|
||||||
|
|
||||||
|
# violations also covered by `ruff` below
|
||||||
|
|
||||||
|
"ANN", # flake8-annotations => enforce type checking for functions
|
||||||
|
"B", # flake8-bugbear => bugs and design flaws
|
||||||
|
"C4", # flake8-comprehensions => better comprehensions
|
||||||
|
"C8", # flake8-commas => better comma placements ("COM" for `ruff`)
|
||||||
|
"C90", # mccabe => cyclomatic complexity (Source: https://github.com/pycqa/mccabe#plugin-for-flake8)
|
||||||
|
"D", # flake8-docstrings / pydocstyle => PEP257 compliance
|
||||||
|
"E", "W", # pycodestyle => PEP8 compliance (Source: https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes)
|
||||||
|
"E800", # flake8-eradicate / eradicate => no commented out code ("ERA" for `ruff`)
|
||||||
|
"F", # pyflakes => basic errors (Source: https://flake8.pycqa.org/en/latest/user/error-codes.html)
|
||||||
|
"I", # flake8-isort => isort would make changes
|
||||||
|
"N", # pep8-naming
|
||||||
|
"PT", # flake8-pytest-style => enforce a consistent style with pytest
|
||||||
|
"Q", # flake8-quotes => use double quotes everywhere (complying with black)
|
||||||
|
"S", # flake8-bandit => common security issues
|
||||||
|
"T10", # flake8-debugger => no debugger usage
|
||||||
|
|
||||||
|
# violations not covered by `ruff` below
|
||||||
|
|
||||||
|
"BLK", # flake8-black => complain if black wants to make changes
|
||||||
|
"DOC", # pydoclint (replaces "darglint") => docstring matches implementation
|
||||||
|
"N400", # flake8-broken-line => no "\" to end a line
|
||||||
|
"P", # flake8-string-format => unify usage of `str.format()` ("FMT" in the future)
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
extend-ignore = [ # never check the following codes
|
||||||
|
|
||||||
|
"ANN101", "ANN102", # `self` and `cls` in methods need no annotation
|
||||||
|
|
||||||
|
"ANN401", # allow dynamically typed expressions with `typing.Any`
|
||||||
|
|
||||||
|
# Comply with black's style
|
||||||
|
# Sources: https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#pycodestyle
|
||||||
|
"E203", "E701", "E704", "W503",
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
per-file-ignores = [
|
||||||
|
|
||||||
|
# Linting rules for the test suite:
|
||||||
|
# - type hints are not required
|
||||||
|
# - `assert`s are normal
|
||||||
|
"tests/*.py:ANN,S101",
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
# Explicitly set mccabe's maximum complexity to 10 as recommended by
|
||||||
|
# Thomas McCabe, the inventor of the McCabe complexity, and the NIST
|
||||||
|
# Source: https://en.wikipedia.org/wiki/Cyclomatic_complexity#Limiting_complexity_during_development
|
||||||
|
max-complexity = 10
|
||||||
|
|
||||||
|
# Whereas black and isort break the line at 88 characters,
|
||||||
|
# make flake8 not complain about anything (e.g., comments) until 100
|
||||||
|
max-line-length = 99
|
||||||
|
|
||||||
|
# Preview the code lines that cause errors
|
||||||
|
show-source = true
|
||||||
|
|
||||||
|
# Plug-in: flake8-docstrings
|
||||||
|
# Source: https://www.pydocstyle.org/en/latest/error_codes.html#default-conventions
|
||||||
|
docstring-convention = "google"
|
||||||
|
|
||||||
|
# Plug-in: flake8-eradicate
|
||||||
|
# Source: https://github.com/wemake-services/flake8-eradicate#options
|
||||||
|
eradicate-aggressive = true
|
||||||
|
|
||||||
|
# Plug-in: flake8-pytest-style
|
||||||
|
#
|
||||||
|
# Aligned with [tool.ruff.lint.flake8-pytest-style] below
|
||||||
|
#
|
||||||
|
# Prefer `@pytest.fixture` over `@pytest.fixture()`
|
||||||
|
pytest-fixture-no-parentheses = true
|
||||||
|
#
|
||||||
|
# Prefer `@pytest.mark.foobar` over `@pytest.mark.foobar()`
|
||||||
|
pytest-mark-no-parentheses = true
|
||||||
|
#
|
||||||
|
# Prefer `@pytest.mark.parametrize(['param1', 'param2'], [(1, 2), (3, 4)])`
|
||||||
|
# over `@pytest.mark.parametrize(('param1', 'param2'), ([1, 2], [3, 4]))`
|
||||||
|
pytest-parametrize-names-type = "list"
|
||||||
|
pytest-parametrize-values-row-type = "tuple"
|
||||||
|
pytest-parametrize-values-type = "list"
|
||||||
|
|
||||||
|
# Plug-in: flake8-quotes
|
||||||
|
# Source: https://github.com/zheller/flake8-quotes#configuration
|
||||||
|
avoid-escape = true
|
||||||
|
docstring-quotes = "double"
|
||||||
|
inline-quotes = "double"
|
||||||
|
multiline-quotes = "double"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.isort] # aligned with [tool.ruff.lint.isort] below
|
||||||
|
# Source: https://pycqa.github.io/isort/docs/configuration/options.html
|
||||||
|
|
||||||
|
known_first_party = ["lalib"]
|
||||||
|
|
||||||
|
atomic = true
|
||||||
|
case_sensitive = true
|
||||||
|
combine_star = true
|
||||||
|
force_alphabetical_sort_within_sections = true
|
||||||
|
lines_after_imports = 2
|
||||||
|
remove_redundant_aliases = true
|
||||||
|
|
||||||
|
# Comply with black's style => Instead of: 'profile = "black"'
|
||||||
|
# Source: https://pycqa.github.io/isort/docs/configuration/profiles.html
|
||||||
|
ensure_newline_before_comments = true
|
||||||
|
force_grid_wrap = 0
|
||||||
|
include_trailing_comma = true
|
||||||
|
line_length = 88
|
||||||
|
multi_line_output = 3
|
||||||
|
split_on_trailing_comma = true
|
||||||
|
use_parentheses = true
|
||||||
|
|
||||||
|
# Comply with Google's Python style guide
|
||||||
|
# => All imports go on a single line (with some exceptions)
|
||||||
|
# Source: https://google.github.io/styleguide/pyguide.html#313-imports-formatting
|
||||||
|
force_single_line = true
|
||||||
|
single_line_exclusions = ["collections.abc", "typing"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
# Source: https://mypy.readthedocs.io/en/latest/config_file.html
|
||||||
|
|
||||||
|
cache_dir = ".cache/mypy"
|
||||||
|
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
|
||||||
|
module = [
|
||||||
|
"nox",
|
||||||
|
"pytest",
|
||||||
|
"semver",
|
||||||
|
"tomli",
|
||||||
|
"xdoctest",
|
||||||
|
]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
# Source: https://docs.pytest.org/en/stable/
|
||||||
|
|
||||||
|
cache_dir = ".cache/pytest"
|
||||||
|
|
||||||
|
addopts = "--strict-markers"
|
||||||
|
console_output_style = "count"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Source: https://docs.astral.sh/ruff/
|
||||||
|
|
||||||
|
cache-dir = ".cache/ruff"
|
||||||
|
|
||||||
|
target-version = "py39" # minimum supported Python version
|
||||||
|
|
||||||
|
indent-width = 4
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
|
||||||
|
# Align with black
|
||||||
|
indent-style = "space"
|
||||||
|
line-ending = "lf"
|
||||||
|
quote-style = "double"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
|
||||||
|
# Format docstrings as well
|
||||||
|
docstring-code-format = true
|
||||||
|
docstring-code-line-length = "dynamic"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.lint] # aligned with [tool.flake8] above
|
||||||
|
|
||||||
|
select = [
|
||||||
|
|
||||||
|
# violations also covered by `flake8` above
|
||||||
|
|
||||||
|
"ANN", # flake8-annotations => enforce type checking for functions
|
||||||
|
"B", # flake8-bugbear => bugs and design flaws
|
||||||
|
"C4", # flake8-comprehensions => better comprehensions
|
||||||
|
"C90", # mccabe => cyclomatic complexity
|
||||||
|
"COM", # "C8" for flake8-commas => better comma placements
|
||||||
|
"D", # flake8-docstrings / pydocstyle => PEP257 compliance
|
||||||
|
"E", "W", # pycodestyle => PEP8 compliance
|
||||||
|
"ERA", # "E800" for flake8-eradicate / eradicate => no commented out code
|
||||||
|
"F", # pyflakes => basic errors
|
||||||
|
"I", # flake8-isort => isort would make changes
|
||||||
|
"N", # pep8-naming
|
||||||
|
"PT", # flake8-pytest-style => enforce a consistent style with pytest
|
||||||
|
"Q", # flake8-quotes => use double quotes everywhere
|
||||||
|
"S", # flake8-bandit => common security issues
|
||||||
|
"T10", # flake8-debugger => no debugger usage
|
||||||
|
|
||||||
|
# violations not covered by `flake8` above
|
||||||
|
|
||||||
|
"T20", # flake8-print => forbid `[p]print`
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
extend-ignore = [ # never check the following codes
|
||||||
|
|
||||||
|
"ANN101", "ANN102", # `self` and `cls` in methods need no annotation
|
||||||
|
|
||||||
|
"ANN401", # allow dynamically typed expressions with `typing.Any`
|
||||||
|
|
||||||
|
# Comply with black's style
|
||||||
|
# Sources: https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#pycodestyle
|
||||||
|
"E203", "E701", # "E704" and "W503" do not exist for `ruff`
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-pytest-style] # aligned with [tool.flake8] above
|
||||||
|
|
||||||
|
# Prefer `@pytest.fixture` over `@pytest.fixture()`
|
||||||
|
fixture-parentheses = false
|
||||||
|
|
||||||
|
# Prefer `@pytest.mark.foobar` over `@pytest.mark.foobar()`
|
||||||
|
mark-parentheses = false
|
||||||
|
|
||||||
|
# Prefer `@pytest.mark.parametrize(['param1', 'param2'], [(1, 2), (3, 4)])`
|
||||||
|
# over `@pytest.mark.parametrize(('param1', 'param2'), ([1, 2], [3, 4]))`
|
||||||
|
parametrize-names-type = "list"
|
||||||
|
parametrize-values-row-type = "tuple"
|
||||||
|
parametrize-values-type = "list"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort] # aligned with [tool.isort] above
|
||||||
|
|
||||||
|
case-sensitive = true
|
||||||
|
force-single-line = true
|
||||||
|
single-line-exclusions = ["collections.abc", "typing"]
|
||||||
|
lines-after-imports = 2
|
||||||
|
split-on-trailing-comma = true
|
||||||
|
known-first-party = ["lalib"]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
|
||||||
|
# The "docs/" folder is not a package
|
||||||
|
"docs/conf.py" = ["INP001"]
|
||||||
|
|
||||||
|
"tests/*.py" = [ # Linting rules for the test suite:
|
||||||
|
"ANN", # - type hints are not required
|
||||||
|
"S101", # - `assert`s are normal
|
||||||
|
"W505", # - docstrings may be longer than 72 characters
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.lint.pycodestyle]
|
||||||
|
|
||||||
|
max-doc-length = 72
|
||||||
|
max-line-length = 99
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
27
src/lalib/__init__.py
Normal file
27
src/lalib/__init__.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
"""A Python library to study linear algebra.
|
||||||
|
|
||||||
|
First, verify that your installation of `lalib` works:
|
||||||
|
>>> import lalib
|
||||||
|
>>> lalib.__version__ != '0.0.0'
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
|
||||||
|
from importlib import metadata
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
pkg_info = metadata.metadata(__name__)
|
||||||
|
|
||||||
|
except metadata.PackageNotFoundError:
|
||||||
|
__author__ = "unknown"
|
||||||
|
__pkg_name__ = "unknown"
|
||||||
|
__version__ = "unknown"
|
||||||
|
|
||||||
|
else:
|
||||||
|
__author__ = pkg_info["author"]
|
||||||
|
__pkg_name__ = pkg_info["name"]
|
||||||
|
__version__ = pkg_info["version"]
|
||||||
|
del pkg_info
|
||||||
|
|
||||||
|
|
||||||
|
del metadata
|
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the `lalib` library."""
|
23
tests/test_docstrings.py
Normal file
23
tests/test_docstrings.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
"""Integrate `xdoctest` into the test suite.
|
||||||
|
|
||||||
|
Ensure all code snippets in docstrings are valid and functioning code.
|
||||||
|
|
||||||
|
Important: All modules with docstrings containing code snippets
|
||||||
|
must be put on the parameter list below by hand!
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import xdoctest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"module",
|
||||||
|
[
|
||||||
|
"lalib",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_docstrings(module):
|
||||||
|
"""Test code snippets within the package with `xdoctest`."""
|
||||||
|
result = xdoctest.doctest_module(module, "all")
|
||||||
|
|
||||||
|
assert result["n_failed"] == 0
|
587
tests/test_version.py
Normal file
587
tests/test_version.py
Normal file
|
@ -0,0 +1,587 @@
|
||||||
|
"""Test the package's version identifier.
|
||||||
|
|
||||||
|
The packaged version identifier (i.e., `lalib.__version__`)
|
||||||
|
adheres to PEP440 and its base part follows semantic versioning:
|
||||||
|
|
||||||
|
- In general, version identifiers follow the "x.y.z" format
|
||||||
|
where x, y, and z are non-negative integers (e.g., "0.1.0"
|
||||||
|
or "1.2.3") matching the "major", "minor", and "patch" parts
|
||||||
|
of semantic versioning
|
||||||
|
|
||||||
|
- Without suffixes, these "x.y.z" versions represent
|
||||||
|
the ordinary releases to PyPI
|
||||||
|
|
||||||
|
- Developmental or non-release versions are indicated
|
||||||
|
with a ".dev0" suffix; we use solely a "0" to keep things simple
|
||||||
|
|
||||||
|
- Pre-releases come as "alpha", "beta", and "release candidate"
|
||||||
|
versions, indicated with "aN", "bN", and "rcN" suffixes
|
||||||
|
(no "." separator before the suffixes) where N is either 1 or 2
|
||||||
|
|
||||||
|
- Post-releases are possible and indicated with a ".postN" suffix
|
||||||
|
where N is between 0 and 9; they must not change the code
|
||||||
|
as compared to their corresponding ordinary release version
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "0.4.2" => ordinary release; public API may be unstable
|
||||||
|
as explained by the rules of semantic versioning
|
||||||
|
- "1.2.3" => ordinary release (long-term stable versions)
|
||||||
|
- "4.5.6.dev0" => early development stage before release "4.5.6"
|
||||||
|
- "4.5.6a1" => pre-release shortly before publication of "4.5.6"
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- https://peps.python.org/pep-0440/
|
||||||
|
- https://semver.org/spec/v2.0.0.html
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- The `packaging` library and the `importlib.metadata` module
|
||||||
|
are very forgiving when parsing version identifiers
|
||||||
|
=> The test cases in this file enforce a strict style
|
||||||
|
|
||||||
|
- The `DECLARED_VERSION` (in pyproject.toml) and the
|
||||||
|
`PACKAGED_VERSION` (read from the metadata after installation
|
||||||
|
in a virtual environment) are tested besides a lot of
|
||||||
|
example `VALID_VERSIONS` to obtain a high confidence
|
||||||
|
in the test cases
|
||||||
|
|
||||||
|
- There are two generic kind of test cases:
|
||||||
|
|
||||||
|
+ `TestVersionIdentifier` uses the `packaging` and `semver
|
||||||
|
libraries to parse the various `*_VERSION`s and validate
|
||||||
|
their infos
|
||||||
|
|
||||||
|
+ `TestVersionIdentifierWithPattern` defines a `regex` pattern
|
||||||
|
comprising all rules at once
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import importlib
|
||||||
|
import itertools
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import semver
|
||||||
|
from packaging import version as pkg_version
|
||||||
|
|
||||||
|
import lalib
|
||||||
|
|
||||||
|
|
||||||
|
# Support Python 3.9 and 3.10
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
import tomli as tomllib # type: ignore[no-redef]
|
||||||
|
|
||||||
|
|
||||||
|
def load_version_from_pyproject_toml():
|
||||||
|
"""The version declared in pyproject.toml."""
|
||||||
|
with pathlib.Path("pyproject.toml").open("rb") as fp:
|
||||||
|
pyproject_toml = tomllib.load(fp)
|
||||||
|
|
||||||
|
return pyproject_toml["tool"]["poetry"]["version"]
|
||||||
|
|
||||||
|
|
||||||
|
def expand_digits_to_versions(
|
||||||
|
digits=string.digits[1:],
|
||||||
|
*,
|
||||||
|
pre_process=(lambda x, y, z: (x, y, z)),
|
||||||
|
filter_=(lambda _x, _y, _z: True),
|
||||||
|
post_process=(lambda x, y, z: (x, y, z)),
|
||||||
|
unique=False,
|
||||||
|
):
|
||||||
|
"""Yield examplatory semantic versions.
|
||||||
|
|
||||||
|
For example, "12345" is expanded into "1.2.345", "1.23.45", ..., "123.4.5".
|
||||||
|
|
||||||
|
In general, the `digits` are sliced into three parts `x`, `y`, and `z`
|
||||||
|
that could be thought of the "major", "minor", and "patch" parts of
|
||||||
|
a version identifier. The `digits` themselves are not re-arranged.
|
||||||
|
|
||||||
|
`pre_process(x, y, z)` transform the parts individually. As an example,
|
||||||
|
`part % 100` makes each part only use the least significant digits.
|
||||||
|
So, in the example, "1.2.345" becomes "1.2.45".
|
||||||
|
|
||||||
|
`post_process(x, y, z)` does the same but only after applying the
|
||||||
|
`filter_(x, y, z)` which signals if the current `x`, `y`, and `z`
|
||||||
|
should be skipped.
|
||||||
|
|
||||||
|
`unique=True` ensures a produced version identifier is yielded only once.
|
||||||
|
"""
|
||||||
|
seen_before = set() if unique else None
|
||||||
|
|
||||||
|
for i in range(1, len(digits) - 1):
|
||||||
|
for j in range(i + 1, len(digits)):
|
||||||
|
x, y, z = int(digits[:i]), int(digits[i:j]), int(digits[j:])
|
||||||
|
|
||||||
|
x, y, z = pre_process(x, y, z)
|
||||||
|
|
||||||
|
if not filter_(x, y, z):
|
||||||
|
continue
|
||||||
|
|
||||||
|
x, y, z = post_process(x, y, z)
|
||||||
|
|
||||||
|
if unique:
|
||||||
|
if (x, y, z) in seen_before:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
seen_before.add((x, y, z))
|
||||||
|
|
||||||
|
yield f"{x}.{y}.{z}"
|
||||||
|
|
||||||
|
|
||||||
|
DECLARED_VERSION = load_version_from_pyproject_toml()
|
||||||
|
PACKAGED_VERSION = lalib.__version__
|
||||||
|
|
||||||
|
VALID_AND_NORMALIZED_VERSIONS = (
|
||||||
|
"0.1.0",
|
||||||
|
"0.1.1",
|
||||||
|
"0.1.99",
|
||||||
|
"0.2.0",
|
||||||
|
"0.99.0",
|
||||||
|
"1.0.0",
|
||||||
|
"1.2.3.dev0",
|
||||||
|
"1.2.3a1",
|
||||||
|
"1.2.3a2",
|
||||||
|
"1.2.3b1",
|
||||||
|
"1.2.3b2",
|
||||||
|
"1.2.3rc1",
|
||||||
|
"1.2.3rc2",
|
||||||
|
"1.2.3",
|
||||||
|
*(f"1.2.3.post{n}" for n in range(10)),
|
||||||
|
# e.g., "1.2.89", "1.23.89", "1.78.9", "12.3.89", "12.34.89", and "67.8.9"
|
||||||
|
*expand_digits_to_versions(
|
||||||
|
"12345689",
|
||||||
|
pre_process=(lambda x, y, z: (x % 100, y % 100, z % 100)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The `packaging` library can parse the following versions
|
||||||
|
# that are then normalized according to PEP440
|
||||||
|
# Source: https://peps.python.org/pep-0440/#normalization
|
||||||
|
VALID_AND_NOT_NORMALIZED_VERSIONS = (
|
||||||
|
"1.2.3dev0",
|
||||||
|
"1.2.3-dev0",
|
||||||
|
"1.2.3_dev0",
|
||||||
|
"1.2.3alpha1",
|
||||||
|
"1.2.3.alpha1",
|
||||||
|
"1.2.3-alpha1",
|
||||||
|
"1.2.3_alpha1",
|
||||||
|
"1.2.3.a1",
|
||||||
|
"1.2.3.a2",
|
||||||
|
"1.2.3beta1",
|
||||||
|
"1.2.3.beta1",
|
||||||
|
"1.2.3-beta1",
|
||||||
|
"1.2.3_beta1",
|
||||||
|
"1.2.3.b1",
|
||||||
|
"1.2.3.b2",
|
||||||
|
"1.2.3c1",
|
||||||
|
"1.2.3.c1",
|
||||||
|
"1.2.3.rc1",
|
||||||
|
"1.2.3c2",
|
||||||
|
"1.2.3.c2",
|
||||||
|
"1.2.3.rc2",
|
||||||
|
"1.2.3post0",
|
||||||
|
"1.2.3-post0",
|
||||||
|
"1.2.3_post0",
|
||||||
|
"1.2.3-r0",
|
||||||
|
"1.2.3-rev0",
|
||||||
|
"1.2.3-0",
|
||||||
|
"1.2.3_post9",
|
||||||
|
)
|
||||||
|
|
||||||
|
VALID_VERSIONS = (
|
||||||
|
DECLARED_VERSION,
|
||||||
|
PACKAGED_VERSION,
|
||||||
|
*VALID_AND_NORMALIZED_VERSIONS,
|
||||||
|
*VALID_AND_NOT_NORMALIZED_VERSIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The following persions cannot be parsed by the `packaging` library
|
||||||
|
INVALID_NOT_READABLE = (
|
||||||
|
"-1.2.3",
|
||||||
|
"+1.2.3",
|
||||||
|
"!1.2.3",
|
||||||
|
"x.2.3",
|
||||||
|
"1.y.3",
|
||||||
|
"1.2.z",
|
||||||
|
"x.y.z",
|
||||||
|
"1.2.3.abc",
|
||||||
|
"1.2.3.d0",
|
||||||
|
"1.2.3.develop0",
|
||||||
|
"1.2.3..dev0",
|
||||||
|
"1..2.3",
|
||||||
|
"1.2..3",
|
||||||
|
"1-2-3",
|
||||||
|
"1,2,3",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The `packaging` library is able to parse the following versions
|
||||||
|
# that however are not considered valid for this project
|
||||||
|
INVALID_NOT_SEMANTIC = (
|
||||||
|
"1",
|
||||||
|
"1.2",
|
||||||
|
"01.2.3",
|
||||||
|
"1.02.3",
|
||||||
|
"1.2.03",
|
||||||
|
"1.2.3.4",
|
||||||
|
"1.2.3.dev-1",
|
||||||
|
"1.2.3.dev01",
|
||||||
|
"v1.2.3",
|
||||||
|
)
|
||||||
|
|
||||||
|
INVALID_VERSIONS = INVALID_NOT_READABLE + INVALID_NOT_SEMANTIC
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["version1", "version2"],
|
||||||
|
zip( # loop over pairs of neighboring elements
|
||||||
|
VALID_AND_NORMALIZED_VERSIONS,
|
||||||
|
VALID_AND_NORMALIZED_VERSIONS[1:],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_versions_are_strictly_ordered(version1, version2):
|
||||||
|
"""`VALID_AND_NORMALIZED_VERSIONS` are ordered."""
|
||||||
|
version1_parsed = pkg_version.Version(version1)
|
||||||
|
version2_parsed = pkg_version.Version(version2)
|
||||||
|
|
||||||
|
assert version1_parsed < version2_parsed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["version1", "version2"],
|
||||||
|
zip( # loop over pairs of neighboring elements
|
||||||
|
VALID_AND_NOT_NORMALIZED_VERSIONS,
|
||||||
|
VALID_AND_NOT_NORMALIZED_VERSIONS[1:],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_versions_are_weakly_ordered(version1, version2):
|
||||||
|
"""`VALID_AND_NOT_NORMALIZED_VERSIONS` are ordered."""
|
||||||
|
version1_parsed = pkg_version.Version(version1)
|
||||||
|
version2_parsed = pkg_version.Version(version2)
|
||||||
|
|
||||||
|
assert version1_parsed <= version2_parsed
|
||||||
|
|
||||||
|
|
||||||
|
class VersionClassification:
|
||||||
|
"""Classifying version identifiers.
|
||||||
|
|
||||||
|
There are four distinct kinds of version identifiers:
|
||||||
|
- "X.Y.Z.devN" => developmental non-releases
|
||||||
|
- "X.Y.Z[aN|bN|rcN]" => pre-releases (e.g., alpha, beta, or release candidates)
|
||||||
|
- "X.Y.Z" => ordinary (or "official) releases to PypI
|
||||||
|
- "X.Y.Z.postN" => post-releases (e.g., to add missing non-code artifacts)
|
||||||
|
|
||||||
|
The `packaging` library models these four cases slightly different, and,
|
||||||
|
most notably in an overlapping fashion (i.e., developmental releases are
|
||||||
|
also pre-releases).
|
||||||
|
|
||||||
|
The four methods in this class introduce our own logic
|
||||||
|
treating the four cases in a non-overlapping fashion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def is_dev_release(self, parsed_version):
|
||||||
|
"""A "X.Y.Z.devN" release."""
|
||||||
|
is_so_by_parts = (
|
||||||
|
parsed_version.dev is not None
|
||||||
|
and parsed_version.pre is None
|
||||||
|
and parsed_version.post is None
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_so_by_parts:
|
||||||
|
assert parsed_version.is_devrelease is True
|
||||||
|
assert parsed_version.is_prerelease is True
|
||||||
|
assert parsed_version.is_postrelease is False
|
||||||
|
|
||||||
|
return is_so_by_parts
|
||||||
|
|
||||||
|
def is_pre_release(self, parsed_version):
|
||||||
|
"""A "X.Y.Z[aN|bN|rcN]" release."""
|
||||||
|
is_so_by_parts = (
|
||||||
|
parsed_version.dev is None
|
||||||
|
and parsed_version.pre is not None
|
||||||
|
and parsed_version.post is None
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_so_by_parts:
|
||||||
|
assert parsed_version.is_devrelease is False
|
||||||
|
assert parsed_version.is_prerelease is True
|
||||||
|
assert parsed_version.is_postrelease is False
|
||||||
|
|
||||||
|
return is_so_by_parts
|
||||||
|
|
||||||
|
def is_ordinary_release(self, parsed_version):
|
||||||
|
"""A "X.Y.Z" release."""
|
||||||
|
is_so_by_parts = (
|
||||||
|
parsed_version.dev is None
|
||||||
|
and parsed_version.pre is None
|
||||||
|
and parsed_version.post is None
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_so_by_parts:
|
||||||
|
assert parsed_version.is_devrelease is False
|
||||||
|
assert parsed_version.is_prerelease is False
|
||||||
|
assert parsed_version.is_postrelease is False
|
||||||
|
|
||||||
|
return is_so_by_parts
|
||||||
|
|
||||||
|
def is_post_release(self, parsed_version):
|
||||||
|
"""A "X.Y.Z.postN" release."""
|
||||||
|
is_so_by_parts = (
|
||||||
|
parsed_version.dev is None
|
||||||
|
and parsed_version.pre is None
|
||||||
|
and parsed_version.post is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_so_by_parts:
|
||||||
|
assert parsed_version.is_devrelease is False
|
||||||
|
assert parsed_version.is_prerelease is False
|
||||||
|
assert parsed_version.is_postrelease is True
|
||||||
|
|
||||||
|
return is_so_by_parts
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionIdentifier(VersionClassification):
|
||||||
|
"""The versions must comply with PEP440 ...
|
||||||
|
|
||||||
|
and follow some additional constraints, most notably that a version's
|
||||||
|
base complies with semantic versioning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_packaged_version_is_declared_version(self):
|
||||||
|
"""`lalib.__version__` matches "version" in pyproject.toml exactly."""
|
||||||
|
assert PACKAGED_VERSION == DECLARED_VERSION
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parsed_version(self, request):
|
||||||
|
"""A version identifier parsed with `packaging.version.Version()`."""
|
||||||
|
return pkg_version.Version(request.param)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unparsed_version(self, request):
|
||||||
|
"""A version identifier represented as an ordinary `str`."""
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("unparsed_version", INVALID_NOT_READABLE)
|
||||||
|
def test_does_not_follow_pep440(self, unparsed_version):
|
||||||
|
"""A version's base does not follow PEP440."""
|
||||||
|
with pytest.raises(pkg_version.InvalidVersion):
|
||||||
|
pkg_version.Version(unparsed_version)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["parsed_version", "unparsed_version"],
|
||||||
|
[(v, v) for v in VALID_VERSIONS],
|
||||||
|
indirect=True,
|
||||||
|
)
|
||||||
|
def test_base_follows_semantic_versioning(self, parsed_version, unparsed_version):
|
||||||
|
"""A version's base follows semantic versioning."""
|
||||||
|
result = semver.Version.parse(parsed_version.base_version)
|
||||||
|
result = str(result)
|
||||||
|
|
||||||
|
assert unparsed_version.startswith(result)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", INVALID_NOT_READABLE + INVALID_NOT_SEMANTIC)
|
||||||
|
def test_base_does_not_follow_semantic_versioning(self, version):
|
||||||
|
"""A version's base does not follow semantic versioning."""
|
||||||
|
with pytest.raises(ValueError, match="not valid SemVer"):
|
||||||
|
semver.Version.parse(version)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_has_major_minor_patch_parts(self, parsed_version):
|
||||||
|
"""A version's base consists of three parts."""
|
||||||
|
three_parts = 3
|
||||||
|
|
||||||
|
assert len(parsed_version.release) == three_parts
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
@pytest.mark.parametrize("part", ["major", "minor", "micro"])
|
||||||
|
def test_major_minor_patch_parts_are_within_range(self, parsed_version, part):
|
||||||
|
"""A version's "major", "minor", and "patch" parts are non-negative and `< 100`."""
|
||||||
|
# "micro" in PEP440 is "patch" in semantic versioning
|
||||||
|
part = getattr(parsed_version, part, -1)
|
||||||
|
two_digits_only = 100
|
||||||
|
|
||||||
|
assert 0 <= part < two_digits_only
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_is_either_dev_pre_post_or_ordinary_release(self, parsed_version):
|
||||||
|
"""A version is exactly one of four kinds."""
|
||||||
|
result = ( # `bool`s behaving like `int`s
|
||||||
|
self.is_dev_release(parsed_version)
|
||||||
|
+ self.is_pre_release(parsed_version)
|
||||||
|
+ self.is_ordinary_release(parsed_version)
|
||||||
|
+ self.is_post_release(parsed_version)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_dev_releases_come_with_dev0(self, parsed_version):
|
||||||
|
"""A ".devN" version always comes with ".dev0"."""
|
||||||
|
if self.is_dev_release(parsed_version):
|
||||||
|
assert parsed_version.dev == 0
|
||||||
|
assert parsed_version.pre is None
|
||||||
|
assert parsed_version.post is None
|
||||||
|
else:
|
||||||
|
assert parsed_version.dev is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_pre_releases_come_with_suffix1_or_suffix2(self, parsed_version):
|
||||||
|
"""A "aN", "bN", or "rcN" version always comes with N as 1 or 2."""
|
||||||
|
if self.is_pre_release(parsed_version):
|
||||||
|
assert parsed_version.dev is None
|
||||||
|
assert parsed_version.pre[1] in (1, 2)
|
||||||
|
assert parsed_version.post is None
|
||||||
|
else:
|
||||||
|
assert parsed_version.pre is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_ordinary_releases_have_no_suffixes(self, parsed_version):
|
||||||
|
"""A ordinary release versions has no suffixes."""
|
||||||
|
if self.is_ordinary_release(parsed_version):
|
||||||
|
assert parsed_version.dev is None
|
||||||
|
assert parsed_version.pre is None
|
||||||
|
assert parsed_version.post is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_post_releases_come_with_post0_to_post9(self, parsed_version):
|
||||||
|
"""A ".postN" version always comes with N as 0 through 9."""
|
||||||
|
if self.is_post_release(parsed_version):
|
||||||
|
assert parsed_version.dev is None
|
||||||
|
assert parsed_version.pre is None
|
||||||
|
assert parsed_version.post in range(10)
|
||||||
|
else:
|
||||||
|
assert parsed_version.post is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_has_no_epoch_segment(self, parsed_version):
|
||||||
|
"""A version has no epoch segment."""
|
||||||
|
assert parsed_version.epoch == 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parsed_version", VALID_VERSIONS, indirect=True)
|
||||||
|
def test_has_no_local_segment(self, parsed_version):
|
||||||
|
"""A parsed_version has no local segment.
|
||||||
|
|
||||||
|
In semantic versioning, the "local" segment is referred
|
||||||
|
to as the "build metadata".
|
||||||
|
"""
|
||||||
|
assert parsed_version.local is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["parsed_version", "unparsed_version"],
|
||||||
|
[
|
||||||
|
(v, v)
|
||||||
|
for v in (
|
||||||
|
DECLARED_VERSION,
|
||||||
|
PACKAGED_VERSION,
|
||||||
|
*VALID_AND_NORMALIZED_VERSIONS,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indirect=True,
|
||||||
|
)
|
||||||
|
def test_is_normalized(self, parsed_version, unparsed_version):
|
||||||
|
"""A version is already normalized.
|
||||||
|
|
||||||
|
For example, a version cannot be "1.2.3.a1"
|
||||||
|
because this gets normalized into "1.2.3a1".
|
||||||
|
"""
|
||||||
|
assert parsed_version.public == unparsed_version
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["parsed_version", "unparsed_version"],
|
||||||
|
[(v, v) for v in VALID_AND_NOT_NORMALIZED_VERSIONS],
|
||||||
|
indirect=True,
|
||||||
|
)
|
||||||
|
def test_is_not_normalized(self, parsed_version, unparsed_version):
|
||||||
|
"""A version is not yet normalized.
|
||||||
|
|
||||||
|
For example, the version "1.2.3.a1"
|
||||||
|
gets normalized into "1.2.3a1".
|
||||||
|
"""
|
||||||
|
assert parsed_version.public != unparsed_version
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionIdentifierWithPattern:
|
||||||
|
"""Test the versioning with a custom `regex` pattern."""
|
||||||
|
|
||||||
|
x_y_z_version = r"^(0|([1-9]\d*))\.(0|([1-9]\d*))\.(0|([1-9]\d*))"
|
||||||
|
suffixes = r"((\.dev0)|(((a)|(b)|(rc))(1|2))|(\.post\d{1}))"
|
||||||
|
version_pattern = re.compile(f"^{x_y_z_version}{suffixes}?$")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"version",
|
||||||
|
[
|
||||||
|
DECLARED_VERSION,
|
||||||
|
PACKAGED_VERSION,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_packaged_and_declared_version(self, version):
|
||||||
|
"""Packaged version follows PEP440 and semantic versioning."""
|
||||||
|
result = self.version_pattern.fullmatch(version)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
# The next two test cases are sanity checks to validate the `version_pattern`.
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", VALID_AND_NORMALIZED_VERSIONS)
|
||||||
|
def test_valid_versioning(self, version):
|
||||||
|
"""A version follows the "x.y.z[.devN|aN|bN|rcN|.postN]" format."""
|
||||||
|
result = self.version_pattern.fullmatch(version)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", INVALID_VERSIONS)
|
||||||
|
def test_invalid_versioning(self, version):
|
||||||
|
"""A version does not follow the "x.y.z[.devN|aN|bN|rcN|.postN]" format."""
|
||||||
|
result = self.version_pattern.fullmatch(version)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnavailablePackageMetadata:
|
||||||
|
"""Pretend only source files are available, without metadata."""
|
||||||
|
|
||||||
|
def find_path_to_package_metadata_folder(self, name):
|
||||||
|
"""Find the path to a locally installed package within a `venv`."""
|
||||||
|
paths = tuple(
|
||||||
|
itertools.chain(
|
||||||
|
*(pathlib.Path(path).glob(f"{name}-*.dist-info/") for path in sys.path),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sanity Check: There must be exactly one folder
|
||||||
|
# for an installed package within a virtual environment
|
||||||
|
assert len(paths) == 1
|
||||||
|
|
||||||
|
return pathlib.Path(paths[0]).relative_to(pathlib.Path.cwd())
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hide_metadata_from_package(self, name):
|
||||||
|
"""Hide the metadata of a locally installed package."""
|
||||||
|
# Rename the metadata folder
|
||||||
|
path = self.find_path_to_package_metadata_folder(name)
|
||||||
|
path.rename(str(path).replace(name, f"{name}.tmp"))
|
||||||
|
|
||||||
|
# (Re-)Load the package with missing metadata
|
||||||
|
package = importlib.import_module(name)
|
||||||
|
importlib.reload(package)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield package
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore the original metadata folder
|
||||||
|
path = self.find_path_to_package_metadata_folder(f"{name}.tmp")
|
||||||
|
path = path.rename(str(path).replace(f"{name}.tmp", name))
|
||||||
|
|
||||||
|
# Reload the package with the original metadata for other tests
|
||||||
|
importlib.reload(package)
|
||||||
|
|
||||||
|
def test_package_without_version_info(self):
|
||||||
|
"""Import `lalib` with no available version info."""
|
||||||
|
with self.hide_metadata_from_package("lalib") as lalib_pkg:
|
||||||
|
assert lalib_pkg.__pkg_name__ == "unknown"
|
||||||
|
assert lalib_pkg.__version__ == "unknown"
|
Loading…
Reference in a new issue