Merge branch 'set-up-project' into 'develop'
This commit is contained in:
commit
d77e96c426
24 changed files with 3730 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.
|
158
README.md
Normal file
158
README.md
Normal file
|
@ -0,0 +1,158 @@
|
|||
# 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).
|
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.dev0"
|
||||
|
||||
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