Merge branch 'set-up-project' into 'develop'

This commit is contained in:
Alexander Hess 2024-09-10 03:33:50 +02:00
commit d77e96c426
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
24 changed files with 3730 additions and 0 deletions

21
.github/workflows/audit.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
.cache/
dist/
poetry.toml
**/__pycache__/
.venv/

48
.pre-commit-config.yaml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
License
=======
.. include:: ../LICENSE.txt

6
docs/reference.rst Normal file
View file

@ -0,0 +1,6 @@
Reference
=========
.. contents::
:local:
:backlinks: none

3
docs/requirements.txt Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

418
pyproject.toml Normal file
View 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
View 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
View file

@ -0,0 +1 @@
"""Tests for the `lalib` library."""

23
tests/test_docstrings.py Normal file
View 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
View 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"