Compare commits
38 commits
Author | SHA1 | Date | |
---|---|---|---|
aeca30b72e | |||
4a5e316d0c | |||
9308633ded | |||
81bbd4ac0f | |||
bfbbbd01e4 | |||
06c26192ad | |||
b2f6155872 | |||
c7f076c35c | |||
6ddb545491 | |||
81912f1a81 | |||
25c718fe6a | |||
f952d95951 | |||
849b786e13 | |||
6bd21ce134 | |||
7e3e67c300 | |||
de740ebb5f | |||
04addacb09 | |||
153094eef5 | |||
cbc1f8fd3a | |||
febed693b8 | |||
08067b3d6e | |||
3d9f990c68 | |||
4c0c7887e5 | |||
06d003b615 | |||
348cd53767 | |||
917c217ca0 | |||
65de932f8d | |||
4c47ca1b17 | |||
ea85c73933 | |||
51c73163e4 | |||
62b25f66d9 | |||
3cfc0db136 | |||
3cecf0d989 | |||
d405c22c90 | |||
d9dcea8379 | |||
9083cebe18 | |||
d507e1f56d | |||
5d2f430893 |
39 changed files with 3927 additions and 413 deletions
2
.github/workflows/audit.yml
vendored
2
.github/workflows/audit.yml
vendored
|
@ -8,7 +8,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.13
|
||||
architecture: x64
|
||||
|
||||
- run: python --version
|
||||
|
|
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
|
@ -8,7 +8,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.13
|
||||
architecture: x64
|
||||
|
||||
- run: python --version
|
||||
|
|
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
@ -8,7 +8,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.13
|
||||
architecture: x64
|
||||
|
||||
- run: python --version
|
||||
|
|
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
@ -16,6 +16,7 @@ jobs:
|
|||
3.10
|
||||
3.11
|
||||
3.12
|
||||
3.13
|
||||
architecture: x64
|
||||
|
||||
- run: python --version
|
||||
|
|
1
.github/workflows/test_coverage.yml
vendored
1
.github/workflows/test_coverage.yml
vendored
|
@ -14,6 +14,7 @@ jobs:
|
|||
3.10
|
||||
3.11
|
||||
3.12
|
||||
3.13
|
||||
architecture: x64
|
||||
|
||||
- run: python --version
|
||||
|
|
2
.github/workflows/test_docstrings.yml
vendored
2
.github/workflows/test_docstrings.yml
vendored
|
@ -8,7 +8,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.13
|
||||
architecture: x64
|
||||
|
||||
- run: python --version
|
||||
|
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
@ -5,7 +5,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
name: test-${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
|
@ -3,7 +3,7 @@ version: 2
|
|||
build:
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
python: "3.13"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
|
|
@ -89,7 +89,7 @@ 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/)).
|
||||
(i.e., [3.13](https://devguide.python.org/versions/)).
|
||||
|
||||
|
||||
#### Code Formatting & Linting
|
||||
|
|
40
noxfile.py
40
noxfile.py
|
@ -57,7 +57,7 @@ def load_supported_python_versions(*, reverse: bool = False) -> list[str]:
|
|||
|
||||
|
||||
SUPPORTED_PYTHONS = load_supported_python_versions(reverse=True)
|
||||
MAIN_PYTHON = "3.12"
|
||||
MAIN_PYTHON = "3.13"
|
||||
|
||||
DOCS_SRC, DOCS_BUILD = ("docs/", ".cache/docs/")
|
||||
TESTS_LOCATION = "tests/"
|
||||
|
@ -205,8 +205,10 @@ def lint(session: nox.Session) -> None:
|
|||
"flake8-isort",
|
||||
"flake8-quotes",
|
||||
"flake8-string-format",
|
||||
"flake8-todos",
|
||||
"flake8-pyproject",
|
||||
"flake8-pytest-style",
|
||||
"flake8-unused-arguments",
|
||||
"mypy",
|
||||
"pep8-naming", # flake8 plug-in
|
||||
"pydoclint[flake8]",
|
||||
|
@ -229,10 +231,15 @@ TEST_DEPENDENCIES = (
|
|||
"packaging",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-randomly",
|
||||
"pytest-repeat",
|
||||
"semver",
|
||||
'typing-extensions; python_version < "3.11"', # to support Python 3.9 & 3.10
|
||||
"xdoctest",
|
||||
)
|
||||
|
||||
TEST_RANDOM_SEED = "--randomly-seed=42"
|
||||
|
||||
|
||||
@nox.session(python=SUPPORTED_PYTHONS)
|
||||
def test(session: nox.Session) -> None:
|
||||
|
@ -248,6 +255,16 @@ def test(session: nox.Session) -> None:
|
|||
args = posargs or (
|
||||
"--cov",
|
||||
"--no-cov-on-fail",
|
||||
*( # If this function is run via the "test-fast" session,
|
||||
( # the following arguments are added:
|
||||
"--cov-fail-under=100",
|
||||
"--smoke-tests-only",
|
||||
"--exitfirst",
|
||||
)
|
||||
if session.env.get("_smoke_tests_only")
|
||||
else ()
|
||||
),
|
||||
TEST_RANDOM_SEED,
|
||||
TESTS_LOCATION,
|
||||
)
|
||||
|
||||
|
@ -284,6 +301,8 @@ def test_coverage_run(session: nox.Session) -> None:
|
|||
session.install(".")
|
||||
install_pinned(session, "coverage", *TEST_DEPENDENCIES)
|
||||
|
||||
session.env["N_RANDOM_DRAWS"] = "10"
|
||||
session.env["NO_CROSS_REFERENCE"] = "true"
|
||||
session.run(
|
||||
"python",
|
||||
"-m",
|
||||
|
@ -291,6 +310,7 @@ def test_coverage_run(session: nox.Session) -> None:
|
|||
"run",
|
||||
"-m",
|
||||
"pytest",
|
||||
TEST_RANDOM_SEED,
|
||||
TESTS_LOCATION,
|
||||
)
|
||||
|
||||
|
@ -329,6 +349,19 @@ def test_docstrings(session: nox.Session) -> None:
|
|||
session.run("xdoctest", "src/lalib")
|
||||
|
||||
|
||||
@nox.session(name="test-fast", python=MAIN_PYTHON)
|
||||
def test_fast(session: nox.Session) -> None:
|
||||
"""Test code with `pytest`, selected (smoke) tests only.
|
||||
|
||||
The (unit) test cases are selected such that their number
|
||||
is minimal but the achieved coverage remains at 100%.
|
||||
"""
|
||||
# See implementation notes in `pre_commit_test_hook()` below
|
||||
session.env["_smoke_tests_only"] = "true"
|
||||
session.env["NO_CROSS_REFERENCE"] = "true"
|
||||
test(session)
|
||||
|
||||
|
||||
@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.
|
||||
|
@ -430,6 +463,11 @@ def start(session: nox.Session) -> None:
|
|||
session.env["PIP_CACHE_DIR"] = ".cache/pip"
|
||||
session.env["PIP_DISABLE_PIP_VERSION_CHECK"] = "true"
|
||||
|
||||
if session.python in ("3.13", "3.12", "3.11"):
|
||||
session.env["PRAGMA_SUPPORT_39_N_310"] = "to support Python 3.9 & 3.10"
|
||||
else:
|
||||
session.env["PRAGMA_SUPPORT_39_N_310"] = f"{_magic_number =}"
|
||||
|
||||
|
||||
def suppress_poetry_export_warning(session: nox.Session) -> None:
|
||||
"""Temporary fix to avoid poetry's warning ...
|
||||
|
|
802
poetry.lock
generated
802
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,7 @@
|
|||
[tool.poetry]
|
||||
|
||||
name = "lalib"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0.dev0"
|
||||
|
||||
authors = [
|
||||
"Alexander Hess <alexander@webartifex.biz>",
|
||||
|
@ -16,6 +16,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
description = "A Python library to study linear algebra"
|
||||
license = "MIT"
|
||||
|
@ -30,6 +31,8 @@ repository = "https://github.com/webartifex/lalib"
|
|||
|
||||
python = "^3.9"
|
||||
|
||||
typing-extensions = [ { python = "<3.11", version = "^4.12" } ] # to support Python 3.9 & 3.10
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
|
@ -55,8 +58,10 @@ flake8-eradicate = "^1.5"
|
|||
flake8-isort = "^6.1"
|
||||
flake8-quotes = "^3.4"
|
||||
flake8-string-format = "^0.3"
|
||||
flake8-todos = "^0.3"
|
||||
flake8-pyproject = "^1.2"
|
||||
flake8-pytest-style = "^2.0"
|
||||
flake8-unused-arguments = "^0.0"
|
||||
mypy = "^1.11"
|
||||
pep8-naming = "^0.14" # flake8 plug-in
|
||||
pydoclint = { extras = ["flake8"], version = "^0.5" }
|
||||
|
@ -74,10 +79,13 @@ coverage = "^7.6"
|
|||
packaging = "^24.1" # to test the version identifier
|
||||
pytest = "^8.3"
|
||||
pytest-cov = "^5.0"
|
||||
pytest-randomly = "^3.15"
|
||||
pytest-repeat = "^0.9"
|
||||
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"
|
||||
|
@ -101,7 +109,7 @@ remove-unused-variables = true
|
|||
# Source: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html
|
||||
|
||||
line-length = 88
|
||||
target-version = ["py312", "py311", "py310", "py39"]
|
||||
target-version = ["py313", "py312", "py311", "py310", "py39"]
|
||||
|
||||
|
||||
|
||||
|
@ -121,6 +129,16 @@ show_missing = true
|
|||
skip_covered = true
|
||||
skip_empty = true
|
||||
|
||||
exclude_lines = [
|
||||
|
||||
# "pragma: no cover"
|
||||
# => Intentionally commented out as we thrive for 100% test coverage
|
||||
|
||||
# PyPI's "typing-extensions" are needed to make `mypy` work
|
||||
"pragma: no cover ${PRAGMA_SUPPORT_39_N_310}",
|
||||
|
||||
]
|
||||
|
||||
|
||||
[tool.coverage.run]
|
||||
|
||||
|
@ -158,7 +176,9 @@ select = [
|
|||
"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
|
||||
"T00", # flake8-todos => unify TODOs
|
||||
"T10", # flake8-debugger => no debugger usage
|
||||
"U100", # flake8-unused-arguments => declared function arguments must be used
|
||||
|
||||
# violations not covered by `ruff` below
|
||||
|
||||
|
@ -177,6 +197,8 @@ extend-ignore = [ # never check the following codes
|
|||
|
||||
"ANN401", # allow dynamically typed expressions with `typing.Any`
|
||||
|
||||
"DOC301", # PEP257 => class constructor's docstring go in `.__init__()`
|
||||
|
||||
# Comply with black's style
|
||||
# Sources: https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#pycodestyle
|
||||
"E203", "E701", "E704", "W503",
|
||||
|
@ -235,6 +257,12 @@ docstring-quotes = "double"
|
|||
inline-quotes = "double"
|
||||
multiline-quotes = "double"
|
||||
|
||||
# Plug-in: flake8-unused-arguments
|
||||
# Source: https://github.com/nhoad/flake8-unused-arguments
|
||||
# Make flake8-unused-arguments behave like ruff's "ARG" error code
|
||||
unused-arguments-ignore-abstract-functions = true
|
||||
unused-arguments-ignore-stub-functions = true
|
||||
|
||||
|
||||
|
||||
[tool.isort] # aligned with [tool.ruff.lint.isort] below
|
||||
|
@ -327,6 +355,7 @@ select = [
|
|||
# violations also covered by `flake8` above
|
||||
|
||||
"ANN", # flake8-annotations => enforce type checking for functions
|
||||
"ARG", # flake8-unused-arguments => declared function arguments must be used
|
||||
"B", # flake8-bugbear => bugs and design flaws
|
||||
"C4", # flake8-comprehensions => better comprehensions
|
||||
"C90", # mccabe => cyclomatic complexity
|
||||
|
@ -340,6 +369,7 @@ select = [
|
|||
"PT", # flake8-pytest-style => enforce a consistent style with pytest
|
||||
"Q", # flake8-quotes => use double quotes everywhere
|
||||
"S", # flake8-bandit => common security issues
|
||||
"TD", # flake8-todos => unify TODOs
|
||||
"T10", # flake8-debugger => no debugger usage
|
||||
|
||||
# violations not covered by `flake8` above
|
||||
|
@ -362,6 +392,8 @@ extend-ignore = [ # never check the following codes
|
|||
|
||||
]
|
||||
|
||||
allowed-confusables = ["â„‚", "â„ť", "â„š"]
|
||||
|
||||
|
||||
[tool.ruff.lint.flake8-pytest-style] # aligned with [tool.flake8] above
|
||||
|
||||
|
|
|
@ -4,10 +4,28 @@ First, verify that your installation of `lalib` works:
|
|||
>>> import lalib
|
||||
>>> lalib.__version__ != '0.0.0'
|
||||
True
|
||||
|
||||
`lalib` exposes its very own "words" (i.e., its public API) at the root
|
||||
of the package. They can be imported all at once with:
|
||||
|
||||
>>> from lalib import *
|
||||
|
||||
In addition to Python's built-in numbers, `lalib` comes with a couple of
|
||||
specific numeric data types, for example, `one` and `zero` representing
|
||||
the two elements of the Galois field `GF2`:
|
||||
|
||||
>>> one + one
|
||||
zero
|
||||
>>> one + zero
|
||||
one
|
||||
"""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
from lalib import domains
|
||||
from lalib import elements
|
||||
from lalib import fields
|
||||
|
||||
|
||||
try:
|
||||
pkg_info = metadata.metadata(__name__)
|
||||
|
@ -24,4 +42,26 @@ else:
|
|||
del pkg_info
|
||||
|
||||
|
||||
Domain = domains.Domain
|
||||
|
||||
gf2, one, zero = elements.gf2, elements.one, elements.zero
|
||||
|
||||
Q, R, C, GF2 = fields.Q, fields.R, fields.C, fields.GF2
|
||||
|
||||
|
||||
del domains
|
||||
del elements
|
||||
del fields
|
||||
del metadata
|
||||
|
||||
|
||||
__all__ = (
|
||||
"Domain",
|
||||
"gf2",
|
||||
"one",
|
||||
"zero",
|
||||
"Q",
|
||||
"R",
|
||||
"C",
|
||||
"GF2",
|
||||
)
|
||||
|
|
13
src/lalib/config.py
Normal file
13
src/lalib/config.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
"""Library-wide default settings."""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
NDIGITS = 12
|
||||
|
||||
THRESHOLD = 1 / (10**NDIGITS)
|
||||
|
||||
MAX_DENOMINATOR = math.trunc(1 / THRESHOLD)
|
||||
|
||||
|
||||
del math
|
144
src/lalib/domains.py
Normal file
144
src/lalib/domains.py
Normal file
|
@ -0,0 +1,144 @@
|
|||
"""A `Domain` for discrete `Vector`s.
|
||||
|
||||
This module defines a `Domain` class wrapping the built-in `frozenset`.
|
||||
It is designed to model domains of discrete vectors and matrices.
|
||||
|
||||
In conventional math, `Domain`s are implicitly thought of as strictly
|
||||
positive natural numbers. For example, a `3`-vector over the reals
|
||||
would then have a `Domain` like below:
|
||||
|
||||
>>> Domain([1, 2, 3])
|
||||
Domain({1, 2, 3})
|
||||
|
||||
However, in Python we commonly start counting at `0`. Therefore,
|
||||
a `3`-vector over the reals has the following `Domain` in `lalib`:
|
||||
|
||||
>>> Domain([0, 1, 2])
|
||||
Domain(3)
|
||||
|
||||
We call such `Domain`s "canonical", and, as a convenience, such
|
||||
`Domain`s can be created by passing a `Vector`'s "length" as an
|
||||
`int`eger argument to the `Domain()` constructor, for example:
|
||||
|
||||
>>> Domain(5)
|
||||
Domain(5)
|
||||
|
||||
Domains do not need to be made of numbers. Instead, we can use,
|
||||
for example, letters or words, or any other `hash`able object.
|
||||
|
||||
>>> Domain(["a", "b", "c"])
|
||||
...
|
||||
|
||||
>>> Domain("abc")
|
||||
...
|
||||
|
||||
>>> Domain(("heads", "tails"))
|
||||
...
|
||||
|
||||
>>> Domain({(1, 23), (4, 56), (7, 89)})
|
||||
...
|
||||
|
||||
>>> Domain({"n_yes": 7, "n_no": 3, "n_total": 10}) # `.keys()` are used
|
||||
...
|
||||
|
||||
>>> Domain(([1, 23], [4, 56], [7, 89]))
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: ...
|
||||
"""
|
||||
|
||||
from collections import abc as collections_abc
|
||||
|
||||
# When giving up support for Python 3.9, we can get rid of `Union`
|
||||
from typing import Union
|
||||
|
||||
|
||||
try:
|
||||
from typing import Self
|
||||
except ImportError: # pragma: no cover to support Python 3.9 & 3.10
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class Domain(frozenset):
|
||||
"""The domain for a `Vector`."""
|
||||
|
||||
@staticmethod
|
||||
def __new__(
|
||||
cls: type[Self],
|
||||
/,
|
||||
labels: Union[collections_abc.Iterable[collections_abc.Hashable], int],
|
||||
) -> Self:
|
||||
"""See docstring for `.__init__()`."""
|
||||
# Because `Domain` objects are immutable by design ...
|
||||
if isinstance(labels, cls):
|
||||
return labels
|
||||
|
||||
if not isinstance(labels, collections_abc.Iterable):
|
||||
try:
|
||||
n_labels = int(labels)
|
||||
except (TypeError, ValueError):
|
||||
msg = "must provide a positive integer"
|
||||
raise TypeError(msg) from None
|
||||
else:
|
||||
if n_labels != labels:
|
||||
msg = "must provide a positive integer"
|
||||
raise ValueError(msg)
|
||||
|
||||
labels = range(n_labels)
|
||||
|
||||
# As we require `Vector`s to have at least one entry,
|
||||
if not labels: # we also enforce this constraint on the `Domain`s
|
||||
msg = "must provide at least one label or a positive integer"
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
return super().__new__(cls, labels)
|
||||
except TypeError:
|
||||
# Provide a nicer error message
|
||||
msg = "must provide hashable labels"
|
||||
raise TypeError(msg) from None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
/,
|
||||
labels: Union[collections_abc.Iterable[collections_abc.Hashable], int],
|
||||
) -> None:
|
||||
"""Create a new domain.
|
||||
|
||||
Args:
|
||||
labels: the domain labels provided by an iterable or
|
||||
a strictly positive `int`eger `n` that then constructs
|
||||
the labels `0`, `1`, ... up to and including `n - 1`
|
||||
|
||||
Returns:
|
||||
domain
|
||||
|
||||
Raises:
|
||||
TypeError: `labels` is not of the specified types
|
||||
ValueError:
|
||||
- if a collection argument contains no elements
|
||||
- if an integer argument is not strictly positive
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Text representation: `Domain(...)`.
|
||||
|
||||
Designed such that `eval(repr(self)) == self`; in other words,
|
||||
the text representation of a `Domain` is valid code on its own
|
||||
evaluating into a (new) `Domain` with the same `labels`.
|
||||
|
||||
See: https://docs.python.org/3/reference/datamodel.html#object.__repr__
|
||||
"""
|
||||
if self.is_canonical:
|
||||
return f"{self.__class__.__name__}({len(self)})"
|
||||
return super().__repr__()
|
||||
|
||||
@property # We do not use `@functools.cached_property`
|
||||
# as this allows writing to the propery
|
||||
def is_canonical(self) -> bool:
|
||||
"""If the `labels` resemble a `range(...)`."""
|
||||
try:
|
||||
cached = self._is_canonical
|
||||
except AttributeError:
|
||||
self._is_canonical: bool = (cached := self == set(range(len(self))))
|
||||
return cached
|
51
src/lalib/elements/__init__.py
Normal file
51
src/lalib/elements/__init__.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
"""Various elements of various fields.
|
||||
|
||||
Import the objects like so:
|
||||
|
||||
>>> from lalib.elements import *
|
||||
|
||||
Then, use them:
|
||||
|
||||
>>> one + zero
|
||||
one
|
||||
>>> one + one
|
||||
zero
|
||||
|
||||
>>> type(one)
|
||||
gf2
|
||||
|
||||
The `gf2` type is similar to the built-in `bool`.
|
||||
To cast objects as `gf2` values:
|
||||
|
||||
>>> gf2(0)
|
||||
zero
|
||||
>>> gf2(1)
|
||||
one
|
||||
|
||||
>>> gf2(42)
|
||||
one
|
||||
>>> gf2(-42)
|
||||
one
|
||||
|
||||
Yet, there is also a `strict` mode where values
|
||||
not equal to `1` or `0` within a `threshold` are not accepted.
|
||||
|
||||
>>> gf2(42, strict=True)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
|
||||
from lalib.elements import galois
|
||||
|
||||
|
||||
gf2, one, zero = galois.gf2, galois.one, galois.zero
|
||||
|
||||
del galois
|
||||
|
||||
|
||||
__all__ = (
|
||||
"gf2",
|
||||
"one",
|
||||
"zero",
|
||||
)
|
630
src/lalib/elements/galois.py
Normal file
630
src/lalib/elements/galois.py
Normal file
|
@ -0,0 +1,630 @@
|
|||
"""A Galois field implementation with two elements.
|
||||
|
||||
This module defines two singleton objects, `one` and `zero`,
|
||||
that follow the rules of a Galois field of two elements,
|
||||
also called `GF2` in `lalib`:
|
||||
|
||||
>>> one + one
|
||||
zero
|
||||
>>> zero + one
|
||||
one
|
||||
>>> one * one
|
||||
one
|
||||
>>> one * zero
|
||||
zero
|
||||
|
||||
They mix with numbers that compare equal to either `1` or `0`,
|
||||
for example:
|
||||
|
||||
>>> one + 1
|
||||
zero
|
||||
>>> 0 * zero
|
||||
zero
|
||||
|
||||
Yet, for numbers not equal to `1` or `0`, this does not work:
|
||||
|
||||
>>> one + 42
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
|
||||
|
||||
Further usage explanations of `one` and `zero`
|
||||
can be found in the various docstrings of the `GF2Element` class.
|
||||
|
||||
This class is also referred to as just "the `gf2` type" outside
|
||||
this module.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import functools
|
||||
import math
|
||||
import numbers
|
||||
from typing import Callable, ClassVar, Literal
|
||||
|
||||
|
||||
try:
|
||||
from typing import Self
|
||||
except ImportError: # pragma: no cover to support Python 3.9 & 3.10
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
from lalib import config
|
||||
|
||||
|
||||
def _to_gf2(
|
||||
value: complex, # `mypy` reads `complex | float | int`
|
||||
/,
|
||||
*,
|
||||
strict: bool,
|
||||
threshold: float,
|
||||
) -> int:
|
||||
"""Cast a number as a possible Galois field value: `1` or `0`.
|
||||
|
||||
By default, the `value` is parsed in a `strict` mode where
|
||||
`value`s equal to `1` or `0` within the specified `threshold`
|
||||
return either `1` or `0` exactly.
|
||||
|
||||
Args:
|
||||
value: to be cast; must behave like a number;
|
||||
for `complex` numbers their `.real` part is used
|
||||
strict: if `True`, only accept `value`s equal to
|
||||
`1` or `0` within the `threshold` as `1` or `0`;
|
||||
otherwise, cast any number different from `0` as `1`
|
||||
threshold: used for the equality checks to find
|
||||
`1`-like and `0`-like `value`s
|
||||
|
||||
Returns:
|
||||
either `1` or `0`
|
||||
|
||||
Raises:
|
||||
TypeError: `value` does not behave like a number
|
||||
ValueError: `value != 1` or `value != 0` in `strict` mode
|
||||
"""
|
||||
try:
|
||||
value = complex(value)
|
||||
except (TypeError, ValueError):
|
||||
msg = "`value` must be a number"
|
||||
raise TypeError(msg) from None
|
||||
|
||||
if not (abs(value.imag) < threshold):
|
||||
msg = "`value` must be either `1`-like or `0`-like"
|
||||
raise ValueError(msg)
|
||||
|
||||
value = value.real
|
||||
|
||||
if math.isnan(value):
|
||||
msg = "`value` must be either `1`-like or `0`-like"
|
||||
raise ValueError(msg)
|
||||
|
||||
if strict:
|
||||
if abs(value - 1) < threshold:
|
||||
return 1
|
||||
if abs(value) < threshold:
|
||||
return 0
|
||||
|
||||
msg = "`value` must be either `1`-like or `0`-like"
|
||||
raise ValueError(msg)
|
||||
|
||||
if abs(value) < threshold and not math.isinf(value):
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
class GF2Meta(abc.ABCMeta):
|
||||
"""Make data type of `one` and `zero` appear to be `gf2`."""
|
||||
|
||||
def __repr__(cls) -> str: # noqa: RUF100,U100
|
||||
"""Text representation for `GF2Element` and sub-classes."""
|
||||
return "gf2"
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class GF2Element(metaclass=GF2Meta):
|
||||
"""A Galois field value: either `one` or `zero`.
|
||||
|
||||
Implements the singleton design pattern such that
|
||||
only one instance per field value exists in the
|
||||
computer's memory, i.e., there is only one `one`
|
||||
and one `zero` object at all times.
|
||||
"""
|
||||
|
||||
_instances: ClassVar = {}
|
||||
_value: int
|
||||
|
||||
@staticmethod
|
||||
def __new__(
|
||||
cls: type[Self],
|
||||
value: object = None,
|
||||
/,
|
||||
*,
|
||||
strict: bool = False,
|
||||
threshold: float = config.THRESHOLD,
|
||||
) -> Self:
|
||||
"""See docstring for `.__init__()`."""
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
|
||||
if value is None:
|
||||
try:
|
||||
value = cls._value
|
||||
except AttributeError:
|
||||
try:
|
||||
return cls._instances[0]
|
||||
except KeyError:
|
||||
msg = "Must create `one` and `zero` first (internal error)"
|
||||
raise RuntimeError(msg) from None
|
||||
|
||||
value = _to_gf2(value, strict=strict, threshold=threshold) # type: ignore[arg-type]
|
||||
|
||||
try:
|
||||
return cls._instances[value]
|
||||
except KeyError:
|
||||
obj = super().__new__(cls)
|
||||
cls._instances[int(obj)] = obj
|
||||
return obj
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: object = None,
|
||||
/,
|
||||
*,
|
||||
strict: bool = True,
|
||||
threshold: float = config.THRESHOLD,
|
||||
) -> None:
|
||||
"""Obtain one of two objects: `one` or `zero`.
|
||||
|
||||
Args:
|
||||
value: to be cast; must behave like a number;
|
||||
for `complex` numbers their `.real` part is used
|
||||
strict: if `True`, only accept `value`s equal to
|
||||
`1` or `0` within the `threshold` as `one` or `zero`;
|
||||
otherwise, cast any number different from `0` as `one`
|
||||
threshold: used for the equality checks to find
|
||||
`1`-like and `0`-like `value`s
|
||||
|
||||
Returns:
|
||||
either `one` or `zero`
|
||||
|
||||
Raises:
|
||||
TypeError: `value` does not behave like a number
|
||||
ValueError: `value != 1` or `value != 0` in `strict` mode
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Text representation: `repr(one)` and `repr(zero)`.
|
||||
|
||||
`eval(repr(self)) == self` must be `True`; in other words,
|
||||
the text representation of an object must be valid code on its
|
||||
own and evaluate into a (new) object with the same value.
|
||||
|
||||
See: https://docs.python.org/3/reference/datamodel.html#object.__repr__
|
||||
"""
|
||||
return "one" if self._value else "zero"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
def __complex__(self) -> complex:
|
||||
"""Cast `self` as a `complex` number: `complex(self)`."""
|
||||
return complex(self._value, 0)
|
||||
|
||||
def __float__(self) -> float:
|
||||
"""Cast `self` as a `float`ing-point number: `float(self)`."""
|
||||
return float(self._value)
|
||||
|
||||
def __int__(self) -> int:
|
||||
"""Cast `self` as a `int`: `int(self)`."""
|
||||
return int(self._value)
|
||||
|
||||
__hash__ = __int__
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Cast `self` as a `bool`ean: `bool(self)`.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> bool(zero)
|
||||
False
|
||||
>>> if zero + one:
|
||||
... result = one
|
||||
... else:
|
||||
... result = zero
|
||||
>>> result
|
||||
one
|
||||
"""
|
||||
return bool(self._value)
|
||||
|
||||
def __abs__(self) -> Self:
|
||||
"""Take the absolute value of `self`: `abs(self)`."""
|
||||
return self
|
||||
|
||||
def __trunc__(self) -> int:
|
||||
"""Truncate `self` to the next `int`: `math.trunc(self)`."""
|
||||
return int(self)
|
||||
|
||||
def __floor__(self) -> int:
|
||||
"""Round `self` down to the next `int`: `math.floor(self)`."""
|
||||
return int(self)
|
||||
|
||||
def __ceil__(self) -> int:
|
||||
"""Round `self` up to the next `int`: `math.ceil(self)`."""
|
||||
return int(self)
|
||||
|
||||
def __round__(self, _ndigits: int = config.NDIGITS) -> int:
|
||||
"""Round `self` to the next `int`: `round(self)`."""
|
||||
return int(self)
|
||||
|
||||
@property
|
||||
def real(self) -> int:
|
||||
"""The `.real` part of a `complex` number.
|
||||
|
||||
For a non-`complex` number this is the number itself.
|
||||
"""
|
||||
# Return an `int` to align with `.imag` above
|
||||
return int(self._value)
|
||||
|
||||
@property
|
||||
def imag(self) -> Literal[0]:
|
||||
"""The `.imag`inary part of a `complex` number.
|
||||
|
||||
For a non-`complex` number this is always `0`.
|
||||
"""
|
||||
# `numbers.Real` returns an `int` here
|
||||
# whereas `numbers.Complex` returns a `float`
|
||||
# => must return an `int` to make `mypy` happy
|
||||
return 0
|
||||
|
||||
def conjugate(self) -> Self:
|
||||
"""The conjugate of a `complex` number.
|
||||
|
||||
For a non-`complex` number this is the number itself.
|
||||
"""
|
||||
return self
|
||||
|
||||
@property
|
||||
def numerator(self) -> int:
|
||||
"""Smallest numerator when expressed as a `Rational` number.
|
||||
|
||||
Either `1` or `0`.
|
||||
|
||||
Reasoning:
|
||||
- `int(one) == 1` => `gf2(1 / 1) == one`
|
||||
- `int(zero) == 0` => `gf2(0 / 1) == zero`
|
||||
|
||||
See also docstring for `.denominator`.
|
||||
"""
|
||||
return int(self)
|
||||
|
||||
@property
|
||||
def denominator(self) -> Literal[1]:
|
||||
"""Smallest denominator when expressed as a `Rational` number.
|
||||
|
||||
Always `1` for `gf2` values.
|
||||
|
||||
See also docstring for `.numerator`.
|
||||
"""
|
||||
return 1
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Comparison: `self == other`.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> zero == one
|
||||
False
|
||||
>>> one == one
|
||||
True
|
||||
>>> one != zero
|
||||
True
|
||||
|
||||
>>> one == 1
|
||||
True
|
||||
>>> one == 42
|
||||
False
|
||||
"""
|
||||
try:
|
||||
other = GF2Element(other, strict=True)
|
||||
except (TypeError, ValueError):
|
||||
return NotImplemented
|
||||
else:
|
||||
# TODO(webartifex): investigate the below issue
|
||||
# https://github.com/webartifex/lalib/issues/1
|
||||
# `one` and `zero` are singletons
|
||||
# => yet, the following does not work with `pytest`
|
||||
# in Python 3.9 & 3.10: `return self is other`
|
||||
# => for now, use the following fix:
|
||||
return int(self) == int(other)
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
"""Comparison: `self < other`.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> zero < one
|
||||
True
|
||||
>>> one < one
|
||||
False
|
||||
|
||||
>>> 0 < one
|
||||
True
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> one < 42
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
try:
|
||||
other = GF2Element(other, strict=True)
|
||||
except TypeError:
|
||||
return NotImplemented
|
||||
except ValueError:
|
||||
msg = "`other` must be either `1`-like or `0`-like"
|
||||
raise ValueError(msg) from None
|
||||
else:
|
||||
return int(self) < int(other)
|
||||
|
||||
def __le__(self, other: object) -> bool:
|
||||
"""Comparison: `self <= other`.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> zero <= one
|
||||
True
|
||||
>>> zero <= zero
|
||||
True
|
||||
>>> one <= zero
|
||||
False
|
||||
|
||||
>>> zero <= 1
|
||||
True
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> zero <= 42
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
# The `numbers.Rational` abstract base class requires both
|
||||
# `.__lt__()` and `.__le__()` to be present alongside
|
||||
# `.__eq__()` => `@functools.total_ordering` is not enough
|
||||
return self == other or self < other
|
||||
|
||||
def _compute(self, other: object, func: Callable) -> Self:
|
||||
"""Run arithmetic operations using `int`s.
|
||||
|
||||
The `gf2` atithmetic operations can transparently be conducted
|
||||
by converting `self` and `other` into `int`s first, and
|
||||
then "do the math".
|
||||
|
||||
Besides the generic arithmetic, this method also handles the
|
||||
casting of non-`gf2` values and various errors occuring
|
||||
along the way.
|
||||
"""
|
||||
try:
|
||||
other = GF2Element(other, strict=True)
|
||||
except TypeError:
|
||||
return NotImplemented
|
||||
except ValueError:
|
||||
msg = "`other` must be a `1`-like or `0`-like value"
|
||||
raise ValueError(msg) from None
|
||||
else:
|
||||
try:
|
||||
return self.__class__(func(int(self), int(other)))
|
||||
except ZeroDivisionError:
|
||||
msg = "division by `0`-like value"
|
||||
raise ZeroDivisionError(msg) from None
|
||||
|
||||
def __pos__(self) -> Self:
|
||||
"""Make `self` positive: `+self`."""
|
||||
return self
|
||||
|
||||
def __neg__(self) -> Self:
|
||||
"""Make `self` negative: `-self`."""
|
||||
return self
|
||||
|
||||
def __add__(self, other: object) -> Self:
|
||||
"""Addition / Subtraction: `self + other` / `self - other`.
|
||||
|
||||
For `gf2`, addition and subtraction are identical. Besides
|
||||
`one + one` which cannot result in a "two", all operations
|
||||
behave as one would expect from `int`s.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> one + one
|
||||
zero
|
||||
>>> one + zero
|
||||
one
|
||||
>>> zero - one
|
||||
one
|
||||
|
||||
>>> zero + 0
|
||||
zero
|
||||
>>> 1 + one
|
||||
zero
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> zero + 42
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
return self._compute(other, lambda s, o: (s + o) % 2)
|
||||
|
||||
__radd__ = __add__
|
||||
__sub__ = __add__
|
||||
__rsub__ = __add__
|
||||
|
||||
def __mul__(self, other: object) -> Self:
|
||||
"""Multiplication: `self * other`.
|
||||
|
||||
Multiplying `gf2` values is like multiplying `int`s.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> one * one
|
||||
one
|
||||
>>> zero * one
|
||||
zero
|
||||
|
||||
>>> 0 * one
|
||||
zero
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> one * 42
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
return self._compute(other, lambda s, o: s * o)
|
||||
|
||||
__rmul__ = __mul__
|
||||
|
||||
def __truediv__(self, other: object) -> Self:
|
||||
"""Division: `self / other` and `self // other`.
|
||||
|
||||
Dividing `gf2` values is like dividing `int`s.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> one / one
|
||||
one
|
||||
>>> zero // one
|
||||
zero
|
||||
|
||||
>>> one / zero
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ZeroDivisionError: ...
|
||||
|
||||
>>> 1 // one
|
||||
one
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> 42 / one
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
return self._compute(other, lambda s, o: s / o)
|
||||
|
||||
__floordiv__ = __truediv__
|
||||
|
||||
def __rtruediv__(self, other: object) -> Self:
|
||||
"""(Reflected) Division: `other / self` and `other // self`.
|
||||
|
||||
See docstring for `.__truediv__()`.
|
||||
"""
|
||||
return self._compute(other, lambda s, o: o / s)
|
||||
|
||||
__rfloordiv__ = __rtruediv__
|
||||
|
||||
def __mod__(self, other: object) -> Self:
|
||||
"""Modulo Division: `self % other`.
|
||||
|
||||
Modulo dividing `gf2` values is like modulo dividing `int`s.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> one % one
|
||||
zero
|
||||
>>> zero % one
|
||||
zero
|
||||
|
||||
>>> one % zero
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ZeroDivisionError: ...
|
||||
|
||||
>>> 1 % one
|
||||
zero
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> 42 % one
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
return self._compute(other, lambda s, o: s % o)
|
||||
|
||||
def __rmod__(self, other: object) -> Self:
|
||||
"""(Reflected) Modulo Division: `other % self`.
|
||||
|
||||
See docstring for `.__mod__()`.
|
||||
"""
|
||||
return self._compute(other, lambda s, o: o % s)
|
||||
|
||||
def __pow__(self, other: object, _modulo: object = None) -> Self:
|
||||
"""Exponentiation: `self ** other`.
|
||||
|
||||
Powers of `gf2` values are like powers of `int`s.
|
||||
|
||||
Example usage:
|
||||
|
||||
>>> one ** one
|
||||
one
|
||||
>>> zero ** one
|
||||
zero
|
||||
>>> one ** zero
|
||||
one
|
||||
|
||||
>>> 1 ** one
|
||||
one
|
||||
|
||||
The `other` object must be either `1`-like or `0`-like:
|
||||
|
||||
>>> 42 ** one
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: ...
|
||||
"""
|
||||
return self._compute(other, lambda s, o: s**o)
|
||||
|
||||
def __rpow__(self, other: object, _modulo: object = None) -> Self:
|
||||
"""(Reflected) Exponentiation: `other ** self`.
|
||||
|
||||
See docstring for `.__pow__()`.
|
||||
"""
|
||||
return self._compute(other, lambda s, o: o**s)
|
||||
|
||||
|
||||
numbers.Rational.register(GF2Element)
|
||||
|
||||
|
||||
# The `GF2One` and `GF2Zero` sub-classes' primary purpose
|
||||
# is to give `one` and `zero` their very own `help()` message
|
||||
|
||||
|
||||
class GF2One(GF2Element):
|
||||
"""The Galois field value `one`."""
|
||||
|
||||
_value = 1
|
||||
|
||||
|
||||
class GF2Zero(GF2Element):
|
||||
"""The Galois field value `zero`."""
|
||||
|
||||
_value = 0
|
||||
|
||||
|
||||
one = GF2One()
|
||||
zero = GF2Zero()
|
||||
|
||||
|
||||
# Outside this module the `GF2Element` is just "the `gf2` type"
|
||||
gf2 = GF2Element
|
||||
|
||||
|
||||
del GF2Meta
|
||||
del GF2One
|
||||
del GF2Zero
|
||||
|
||||
del config
|
32
src/lalib/fields/__init__.py
Normal file
32
src/lalib/fields/__init__.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
"""A collection of common fields used in linear algebra."""
|
||||
|
||||
from lalib.fields import base
|
||||
from lalib.fields import complex_
|
||||
from lalib.fields import galois
|
||||
from lalib.fields import rational
|
||||
from lalib.fields import real
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
Field = base.Field
|
||||
|
||||
Q = rational.Q
|
||||
R = real.R
|
||||
C = complex_.C
|
||||
GF2 = galois.GF2
|
||||
|
||||
|
||||
del base
|
||||
del complex_
|
||||
del galois
|
||||
del rational
|
||||
del real
|
||||
del utils # `import`ed and `del`eted to not be in the final namespace
|
||||
|
||||
|
||||
__all__ = (
|
||||
"Q",
|
||||
"R",
|
||||
"C",
|
||||
"GF2",
|
||||
)
|
227
src/lalib/fields/base.py
Normal file
227
src/lalib/fields/base.py
Normal file
|
@ -0,0 +1,227 @@
|
|||
"""The abstract blueprint of a `Field`."""
|
||||
|
||||
import abc
|
||||
import numbers
|
||||
from typing import Any, Callable, Generic, TypeVar
|
||||
|
||||
|
||||
T = TypeVar("T", bound=numbers.Real)
|
||||
|
||||
|
||||
class Field(abc.ABC, Generic[T]):
|
||||
"""The abstract blueprint of a mathematical field."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _math_name(self) -> str:
|
||||
"""The common abbreviation used in math notation."""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def _dtype(value: Any, /) -> T:
|
||||
"""Data type to store the `Field` elements."""
|
||||
|
||||
def _cast_func(self, value: Any, /, **kwargs: Any) -> T:
|
||||
"""Function to cast `value`s as field elements."""
|
||||
return self._dtype(value, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _post_cast_filter(possible_element: T, /) -> bool: # noqa: ARG004
|
||||
"""Function to filter out castable `value`s.
|
||||
|
||||
Called after a successfull call of the `._cast_func()`.
|
||||
|
||||
For example, if one wants to avoid non-finite `Field` elements,
|
||||
an overwriting `._post_cast_filter()` could return `False` for
|
||||
non-finite `value`s like `float("NaN")`.
|
||||
|
||||
By default, all castable `value`s may become `Field` elements.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _additive_identity(self) -> T:
|
||||
"""The field's additive identity."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _multiplicative_identity(self) -> T:
|
||||
"""The field's multiplicative identity."""
|
||||
|
||||
def cast(
|
||||
self,
|
||||
value: object,
|
||||
/,
|
||||
**cast_kwargs: Any,
|
||||
) -> T:
|
||||
"""Cast a (numeric) `value` as an element of the field.
|
||||
|
||||
Args:
|
||||
value: to be cast as the "right" data type
|
||||
as defined in the concrete `Field` sub-class
|
||||
**cast_kwargs: extra `kwargs` to the `._cast_func()`
|
||||
|
||||
Returns:
|
||||
element: of the concrete `Field`
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not an element of the field
|
||||
"""
|
||||
try:
|
||||
element = self._cast_func(value, **cast_kwargs) # type: ignore[arg-type]
|
||||
except (ArithmeticError, TypeError, ValueError):
|
||||
msg = "`value` is not an element of the field"
|
||||
raise ValueError(msg) from None
|
||||
|
||||
if not self._post_cast_filter(element):
|
||||
msg = "`value` is not an element of the field"
|
||||
raise ValueError(msg)
|
||||
|
||||
return element
|
||||
|
||||
def validate(
|
||||
self,
|
||||
value: object,
|
||||
/,
|
||||
*,
|
||||
silent: bool = True,
|
||||
**cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if a (numeric) `value` is an element of the `Field`.
|
||||
|
||||
Wraps `.cast()`, catches the documented `ValueError`,
|
||||
and returns a `bool`ean indicating field membership.
|
||||
|
||||
Args:
|
||||
value: see docstring for `.cast()`
|
||||
silent: suppress the `ValueError`
|
||||
**cast_kwargs: extra `kwargs` to `.cast()` the `value`
|
||||
|
||||
Returns:
|
||||
is_element
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
(suppressed by default)
|
||||
"""
|
||||
try:
|
||||
self.cast(value, **cast_kwargs)
|
||||
except ValueError:
|
||||
if not silent:
|
||||
raise
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def dtype(self) -> Callable[[Any], T]:
|
||||
"""Data type to store the `Field` elements."""
|
||||
return self._dtype
|
||||
|
||||
@property
|
||||
def zero(self) -> T:
|
||||
"""The field's additive identity."""
|
||||
return self._additive_identity
|
||||
|
||||
def is_zero(self, value: T, /, **cast_kwargs: Any) -> bool:
|
||||
"""Check if `value` equals the `.zero`-like field element.
|
||||
|
||||
This method, together with `.is_one()` below, provides a unified
|
||||
way across the different `Field`s to check if a given `value`
|
||||
equals the field's additive or multiplicative identity.
|
||||
|
||||
Concrete `Field`s may use a different logic. For example, some
|
||||
compare the absolute difference between the `value` and the
|
||||
`.zero`-or-`.one`-like field element to a small `threshold`.
|
||||
|
||||
Overwriting methods should
|
||||
- check the `value` for field membership first, and
|
||||
- accept arbitrary keyword-only arguments
|
||||
that they may simply ignore
|
||||
|
||||
Args:
|
||||
value: see docstring for `.cast()`
|
||||
**cast_kwargs: extra `kwargs` to `.cast()` the `value`
|
||||
|
||||
Returns:
|
||||
is_zero
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return self.cast(value, **cast_kwargs) == self._additive_identity
|
||||
|
||||
@property
|
||||
def one(self) -> T:
|
||||
"""The field's multiplicative identity."""
|
||||
return self._multiplicative_identity
|
||||
|
||||
def is_one(self, value: T, /, **cast_kwargs: Any) -> bool:
|
||||
"""Check if `value` equals the `.one`-like field element.
|
||||
|
||||
See docstring for `.is_zero()` above for more details.
|
||||
|
||||
Args:
|
||||
value: see docstring for `.cast()`
|
||||
**cast_kwargs: extra `kwargs` to `.cast()` the `value`
|
||||
|
||||
Returns:
|
||||
is_one
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return self.cast(value, **cast_kwargs) == self._multiplicative_identity
|
||||
|
||||
@abc.abstractmethod
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: T,
|
||||
upper: T,
|
||||
**cast_kwargs: Any,
|
||||
) -> T:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
Overwriting methods should sort them if necessary.
|
||||
|
||||
Extra keyword arguments should be passed through to `.cast()`.
|
||||
"""
|
||||
|
||||
def _get_bounds(
|
||||
self,
|
||||
lower: T,
|
||||
upper: T,
|
||||
/,
|
||||
**cast_kwargs: Any,
|
||||
) -> tuple[T, T]:
|
||||
"""Get the `lower` & `upper` bounds for `Field.random()`.
|
||||
|
||||
Utility method to either
|
||||
- resolve the given `lower` and `upper` bounds into a
|
||||
`.cast()`ed element of the `Field`, or
|
||||
- obtain their default `value`s, which are
|
||||
+ the `.additive_identity` for `lower`, and
|
||||
+ the `.multiplicative_identity` for `upper`
|
||||
|
||||
Extra keyword arguments are passed through to `.cast()`.
|
||||
"""
|
||||
lower = lower if lower is not None else self._additive_identity
|
||||
upper = upper if upper is not None else self._multiplicative_identity
|
||||
|
||||
return (self.cast(lower, **cast_kwargs), self.cast(upper, **cast_kwargs))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Text representations: `repr(...)` and `str(...)`.
|
||||
|
||||
The `.math_name` should be a valid name within `lalib`.
|
||||
If not, use the "<cls ...>" convention; in other words,
|
||||
the text representation is no valid code on its own.
|
||||
|
||||
See: https://docs.python.org/3/reference/datamodel.html#object.__repr__
|
||||
"""
|
||||
return self._math_name
|
||||
|
||||
__str__ = __repr__
|
110
src/lalib/fields/complex_.py
Normal file
110
src/lalib/fields/complex_.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
"""The concrete `ComplexField`."""
|
||||
|
||||
import cmath
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class ComplexField(utils.SingletonMixin, base.Field):
|
||||
"""The `Field` over â„‚, the complex numbers."""
|
||||
|
||||
_math_name = "â„‚"
|
||||
_dtype = complex
|
||||
_post_cast_filter = cmath.isfinite
|
||||
_additive_identity = 0 + 0j
|
||||
_multiplicative_identity = 1 + 0j
|
||||
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: complex = _additive_identity,
|
||||
upper: complex = _multiplicative_identity,
|
||||
ndigits: int = config.NDIGITS,
|
||||
**_cast_kwargs: Any,
|
||||
) -> complex:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
The `.real` and `.imag`inary parts of the `lower` and `upper`
|
||||
bounds evaluated separately; i.e., the `random_element` is drawn
|
||||
from a rectangle with opposing corners `lower` and `upper`.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
ndigits: no. of significant digits to the right of the ".";
|
||||
both the `.real` and the `.imag`inary parts are rounded
|
||||
|
||||
Returns:
|
||||
random_element
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper)
|
||||
|
||||
random_real, random_imag = (
|
||||
# `random.uniform()` can handle `upper < lower`
|
||||
round(random.uniform(lower.real, upper.real), ndigits), # noqa: S311
|
||||
round(random.uniform(lower.imag, upper.imag), ndigits), # noqa: S311
|
||||
)
|
||||
|
||||
return complex(random_real, random_imag)
|
||||
|
||||
def is_zero(
|
||||
self,
|
||||
value: complex,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `0.0 + 0.0j`.
|
||||
|
||||
To be precise: Check if `value` deviates by less than the
|
||||
`threshold` from `0.0 + 0.0j` in absolute terms.
|
||||
|
||||
Args:
|
||||
value: to be compared to `0.0 + 0.0j`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_zero
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value)) < threshold
|
||||
|
||||
def is_one(
|
||||
self,
|
||||
value: complex,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `1.0 + 0.0j`.
|
||||
|
||||
To be precise: Check if `value` deviates by less than the
|
||||
`threshold` from `1.0 + 0.0j` in absolute terms.
|
||||
|
||||
Args:
|
||||
value: to be compared to `1.0 + 0.0j`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_one
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value) - (1.0 + 0j)) < threshold
|
||||
|
||||
|
||||
C = ComplexField()
|
58
src/lalib/fields/galois.py
Normal file
58
src/lalib/fields/galois.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
"""The concrete `GaloisField2`."""
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.elements import galois as gf2_elements
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class GaloisField2(utils.SingletonMixin, base.Field):
|
||||
"""The Galois `Field` of 2 elements."""
|
||||
|
||||
_math_name = "GF2"
|
||||
_dtype = gf2_elements.GF2Element
|
||||
|
||||
def _cast_func(
|
||||
self,
|
||||
value: Any,
|
||||
/,
|
||||
*,
|
||||
strict: bool = True,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_kwargs: Any,
|
||||
) -> gf2_elements.GF2Element:
|
||||
return gf2_elements.gf2(value, strict=strict, threshold=threshold)
|
||||
|
||||
_additive_identity = gf2_elements.zero
|
||||
_multiplicative_identity = gf2_elements.one
|
||||
|
||||
def random(
|
||||
self,
|
||||
lower: gf2_elements.GF2Element = gf2_elements.zero,
|
||||
upper: gf2_elements.GF2Element = gf2_elements.one,
|
||||
**cast_kwargs: Any,
|
||||
) -> gf2_elements.GF2Element:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
**cast_kwargs: extra `kwargs` to `.cast()`
|
||||
the `lower` and `upper` bounds
|
||||
|
||||
Returns:
|
||||
random_element: either `one` or `zero`
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper, **cast_kwargs)
|
||||
|
||||
# `random.choice()` can handle `upper < lower`
|
||||
return random.choice((lower, upper)) # noqa: S311
|
||||
|
||||
|
||||
GF2 = GaloisField2()
|
90
src/lalib/fields/rational.py
Normal file
90
src/lalib/fields/rational.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
"""The concrete `RationalField`."""
|
||||
|
||||
import fractions
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class RationalField(utils.SingletonMixin, base.Field):
|
||||
"""The `Field` over â„š, the rational numbers.
|
||||
|
||||
Although `Q.cast()` accepts `float`s as possible field elements,
|
||||
do so only with care as `float`s are inherently imprecise numbers:
|
||||
|
||||
>>> 0.1 + 0.2
|
||||
0.30000000000000004
|
||||
|
||||
To mitigate this, `Q.cast()` cuts off the decimals, as configured
|
||||
with the `MAX_DENOMINATOR` setting. So, `float`s with just a couple
|
||||
of digits return the possibly desired field element. For example:
|
||||
|
||||
>>> Q.cast(0.1)
|
||||
Fraction(1, 10)
|
||||
|
||||
Yet, with the hidden `max_denominator` argument, we can easily
|
||||
see how `float`s may result in "weird" `Fraction`s.
|
||||
|
||||
>>> Q.cast(0.1, max_denominator=1_000_000_000_000)
|
||||
Fraction(1, 10)
|
||||
>>> Q.cast(0.1, max_denominator=1_000_000_000_000_000_000)
|
||||
Fraction(3602879701896397, 36028797018963968)
|
||||
|
||||
It is recommended to use `str`ings instead:
|
||||
|
||||
>>> Q.cast("0.1")
|
||||
Fraction(1, 10)
|
||||
>>> Q.cast("1/10")
|
||||
Fraction(1, 10)
|
||||
"""
|
||||
|
||||
_math_name = "â„š"
|
||||
_dtype = fractions.Fraction
|
||||
|
||||
def _cast_func(
|
||||
self,
|
||||
value: Any,
|
||||
/,
|
||||
max_denominator: int = config.MAX_DENOMINATOR,
|
||||
**_kwargs: Any,
|
||||
) -> fractions.Fraction:
|
||||
return fractions.Fraction(value).limit_denominator(max_denominator)
|
||||
|
||||
_additive_identity = fractions.Fraction(0, 1)
|
||||
_multiplicative_identity = fractions.Fraction(1, 1)
|
||||
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: fractions.Fraction = _additive_identity,
|
||||
upper: fractions.Fraction = _multiplicative_identity,
|
||||
max_denominator: int = config.MAX_DENOMINATOR,
|
||||
**_cast_kwargs: Any,
|
||||
) -> fractions.Fraction:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
max_denominator: maximum for `random_element.denominator`
|
||||
|
||||
Returns:
|
||||
random_element
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper)
|
||||
|
||||
# `random.uniform()` can handle `upper < lower`
|
||||
random_value = random.uniform(float(lower), float(upper)) # noqa: S311
|
||||
|
||||
return self._cast_func(random_value).limit_denominator(max_denominator)
|
||||
|
||||
|
||||
Q = RationalField()
|
97
src/lalib/fields/real.py
Normal file
97
src/lalib/fields/real.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
"""The concrete `RealField`."""
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class RealField(utils.SingletonMixin, base.Field):
|
||||
"""The `Field` over â„ť, the real numbers."""
|
||||
|
||||
_math_name = "â„ť"
|
||||
_dtype = float
|
||||
_post_cast_filter = math.isfinite
|
||||
_additive_identity = 0.0
|
||||
_multiplicative_identity = 1.0
|
||||
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: float = _additive_identity,
|
||||
upper: float = _multiplicative_identity,
|
||||
ndigits: int = config.NDIGITS,
|
||||
**_cast_kwargs: Any,
|
||||
) -> float:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
ndigits: no. of significant digits to the right of the "."
|
||||
|
||||
Returns:
|
||||
random_element
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper)
|
||||
|
||||
# `random.uniform()` can handle `upper < lower`
|
||||
lower, upper = float(lower), float(upper)
|
||||
rand_value = random.uniform(lower, upper) # noqa: S311
|
||||
|
||||
return round(rand_value, ndigits)
|
||||
|
||||
def is_zero(
|
||||
self,
|
||||
value: float,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `0.0`.
|
||||
|
||||
Args:
|
||||
value: to be compared to `0.0`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_zero (boolean)
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value)) < threshold
|
||||
|
||||
def is_one(
|
||||
self,
|
||||
value: float,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `1.0`.
|
||||
|
||||
Args:
|
||||
value: to be compared to `1.0`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_one
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value) - 1.0) < threshold
|
||||
|
||||
|
||||
R = RealField()
|
22
src/lalib/fields/utils.py
Normal file
22
src/lalib/fields/utils.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
"""Generic utilities for the library."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
try:
|
||||
from typing import Self
|
||||
except ImportError: # pragma: no cover to support Python 3.9 & 3.10
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class SingletonMixin:
|
||||
"""Utility class to provide singleton pattern implementation."""
|
||||
|
||||
_instance: Self
|
||||
|
||||
@staticmethod
|
||||
def __new__(cls: type[Self], *args: Any, **kwargs: Any) -> Self:
|
||||
"""Check if the `_instance` already exists."""
|
||||
if getattr(cls, "_instance", None) is None:
|
||||
cls._instance = super().__new__(cls, *args, **kwargs)
|
||||
return cls._instance
|
59
tests/conftest.py
Normal file
59
tests/conftest.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
"""Configurations and utilities for all tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Define custom CLI options for `pytest`."""
|
||||
parser.addoption(
|
||||
"--smoke-tests-only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run the minimum number of (unit) tests to achieve full coverage",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Define custom markers explicitly."""
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"integration_test: non-unit test case; skipped during coverage reporting",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"overlapping_test: test case not contributing towards higher coverage",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"sanity_test: test case providing confidence in the test data",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Pre-process the test cases programatically.
|
||||
|
||||
- Add `no_cover` marker to test cases with any of these markers:
|
||||
+ "integration_test"
|
||||
+ "overlapping_test"
|
||||
+ "sanity_test"
|
||||
- Select test cases with none of the above markers as smoke tests,
|
||||
i.e., the minimum number of test cases to achieve 100% coverage
|
||||
"""
|
||||
smoke_tests = []
|
||||
|
||||
for item in items:
|
||||
if (
|
||||
"integration_test" in item.keywords
|
||||
or "overlapping_test" in item.keywords
|
||||
or "sanity_test" in item.keywords
|
||||
):
|
||||
item.add_marker(pytest.mark.no_cover)
|
||||
|
||||
elif config.getoption("--smoke-tests-only"):
|
||||
smoke_tests.append(item)
|
||||
|
||||
if config.getoption("--smoke-tests-only"):
|
||||
if not smoke_tests:
|
||||
pytest.exit("No smoke tests found")
|
||||
|
||||
items[:] = smoke_tests
|
1
tests/elements/__init__.py
Normal file
1
tests/elements/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the `lalib.elements` sub-package."""
|
803
tests/elements/test_galois.py
Normal file
803
tests/elements/test_galois.py
Normal file
|
@ -0,0 +1,803 @@
|
|||
"""Test the `gf2` singeltons `one` and `zero`."""
|
||||
|
||||
import decimal
|
||||
import fractions
|
||||
import importlib
|
||||
import math
|
||||
import numbers
|
||||
import operator
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib.elements import galois
|
||||
from tests import utils
|
||||
|
||||
|
||||
gf2, one, zero = ( # official API outside of `lalib.elements.galois`
|
||||
galois.gf2,
|
||||
galois.one,
|
||||
galois.zero,
|
||||
)
|
||||
|
||||
GF2Element, GF2One, GF2Zero = ( # not part of the official API
|
||||
galois.GF2Element,
|
||||
type(galois.one), # The `GF2One` and `GF2Zero` sub-classes
|
||||
type(galois.zero), # are deleted in `lalib.elements.galois`
|
||||
)
|
||||
|
||||
del galois
|
||||
|
||||
|
||||
CROSS_REFERENCE = not os.environ.get("NO_CROSS_REFERENCE")
|
||||
|
||||
|
||||
strict_one_like_values = (
|
||||
1,
|
||||
1.0,
|
||||
1.0 + utils.WITHIN_THRESHOLD,
|
||||
(1 + 0j),
|
||||
(1 + 0j) + complex(0, utils.WITHIN_THRESHOLD),
|
||||
(1 + 0j) + complex(utils.WITHIN_THRESHOLD, 0),
|
||||
decimal.Decimal("1"),
|
||||
fractions.Fraction(1, 1),
|
||||
"1",
|
||||
"1.0",
|
||||
"1+0j",
|
||||
)
|
||||
|
||||
non_strict_one_like_values = (
|
||||
0.0 + utils.NOT_WITHIN_THRESHOLD,
|
||||
1.0 + utils.NOT_WITHIN_THRESHOLD,
|
||||
(1 + 0j) + complex(utils.NOT_WITHIN_THRESHOLD, 0),
|
||||
42,
|
||||
decimal.Decimal("42"),
|
||||
fractions.Fraction(42, 1),
|
||||
"42",
|
||||
"42.0",
|
||||
"42+0j",
|
||||
"+inf",
|
||||
"-inf",
|
||||
)
|
||||
|
||||
one_like_values = strict_one_like_values + non_strict_one_like_values
|
||||
|
||||
zero_like_values = (
|
||||
0,
|
||||
0.0,
|
||||
0.0 + utils.WITHIN_THRESHOLD,
|
||||
(0 + 0j),
|
||||
(0 + 0j) + complex(0, utils.WITHIN_THRESHOLD),
|
||||
(0 + 0j) + complex(utils.WITHIN_THRESHOLD, 0),
|
||||
decimal.Decimal("0"),
|
||||
fractions.Fraction(0, 1),
|
||||
"0",
|
||||
"0.0",
|
||||
"0+0j",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
class TestGF2SubClasses:
|
||||
"""Test the sub-classes behind `one` and `zero`."""
|
||||
|
||||
def test_gf2_is_an_alias(self):
|
||||
"""The "`gf2` type" is really just `GF2Element`."""
|
||||
assert gf2 is GF2Element
|
||||
|
||||
@pytest.mark.parametrize("cls", [GF2One, GF2Zero])
|
||||
def test_sub_classes_for_gf2(self, cls):
|
||||
"""`GF2One` and `GF2Zero` are sub-classes of `gf2`."""
|
||||
assert issubclass(cls, gf2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [gf2, GF2One, GF2Zero])
|
||||
class TestGF2Casting:
|
||||
"""Test the `gf2` class's constructor.
|
||||
|
||||
`gf2(value, ...)` returns either `one` or `zero`.
|
||||
|
||||
The sub-classes behind `one` and `zero` provide the
|
||||
same functionality as `gf2` and have the sole purpose
|
||||
of providing a unique `help()` message for `one` and `zero`.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("value", strict_one_like_values)
|
||||
def test_cast_ones_strictly(self, cls, value):
|
||||
"""`gf2(value, strict=True)` returns `one`."""
|
||||
result1 = cls(value) # `strict=True` by default
|
||||
assert result1 is one
|
||||
|
||||
result2 = cls(value, strict=True)
|
||||
assert result2 is one
|
||||
|
||||
@pytest.mark.parametrize("value", one_like_values)
|
||||
def test_cast_ones_not_strictly(self, cls, value):
|
||||
"""`gf2(value, strict=False)` returns `one`."""
|
||||
result1 = cls(value)
|
||||
assert result1 is one
|
||||
|
||||
result2 = cls(value, strict=False)
|
||||
assert result2 is one
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("value", non_strict_one_like_values)
|
||||
def test_cannot_cast_ones_strictly(self, cls, value):
|
||||
"""`gf2(value, strict=True)` needs strict `1`-like values."""
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
cls(value, strict=True)
|
||||
|
||||
@pytest.mark.parametrize("value", zero_like_values)
|
||||
def test_cast_zeros(self, cls, value):
|
||||
"""`gf2(value, strict=...)` returns `zero`."""
|
||||
result1 = cls(value) # `strict=True` by default
|
||||
assert result1 is zero
|
||||
|
||||
result2 = cls(value, strict=True)
|
||||
assert result2 is zero
|
||||
|
||||
result3 = cls(value, strict=False)
|
||||
assert result3 is zero
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
complex(1, utils.NOT_WITHIN_THRESHOLD),
|
||||
complex(0, utils.NOT_WITHIN_THRESHOLD),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("strict", [True, False])
|
||||
def test_cannot_cast_with_non_zero_imag_part(self, cls, value, strict):
|
||||
"""Cannot create `one` or `zero` if `.imag != 0`."""
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
cls(value, strict=strict)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("value", ["abc", (1,), [1]])
|
||||
@pytest.mark.parametrize("strict", [True, False])
|
||||
def test_cannot_cast_from_wrong_type(self, cls, value, strict):
|
||||
"""Cannot create `one` or `zero` from a non-numeric value."""
|
||||
with pytest.raises(TypeError):
|
||||
cls(value, strict=strict)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("strict", [True, False])
|
||||
def test_cannot_cast_from_nan_value(self, cls, strict):
|
||||
"""Cannot create `one` or `zero` from undefined value."""
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
cls(float("NaN"), strict=strict)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("scaler", [1, 10, 100, 1000])
|
||||
def test_get_one_if_within_threshold(self, cls, scaler):
|
||||
"""`gf2()` returns `one` if `value` is larger than `threshold`."""
|
||||
# `NOT_WITHIN_THRESHOLD` is larger than the `DEFAULT_THRESHOLD`
|
||||
# but still different from `1` => `strict=False`
|
||||
value = scaler * utils.NOT_WITHIN_THRESHOLD
|
||||
threshold = scaler * utils.DEFAULT_THRESHOLD
|
||||
|
||||
result = cls(value, strict=False, threshold=threshold)
|
||||
assert result is one
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("scaler", [1, 10, 100, 1000])
|
||||
@pytest.mark.parametrize("strict", [True, False])
|
||||
def test_get_zero_if_within_threshold(self, cls, scaler, strict):
|
||||
"""`gf2()` returns `zero` if `value` is smaller than `threshold`."""
|
||||
# `WITHIN_THRESHOLD` is smaller than the `DEFAULT_THRESHOLD`
|
||||
value = scaler * utils.WITHIN_THRESHOLD
|
||||
threshold = scaler * utils.DEFAULT_THRESHOLD
|
||||
|
||||
result = cls(value, strict=strict, threshold=threshold)
|
||||
assert result is zero
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("strict", [True, False])
|
||||
class TestGF2ConstructorWithoutCastedValue:
|
||||
"""Test the `gf2` class's constructor.
|
||||
|
||||
`gf2()` returns either `one` or `zero`.
|
||||
"""
|
||||
|
||||
def test_get_one_from_sub_class_with_no_input_value(self, strict):
|
||||
"""`GF2One()` returns `one`."""
|
||||
result = GF2One(strict=strict)
|
||||
assert result is one
|
||||
|
||||
@pytest.mark.parametrize("cls", [gf2, GF2Zero])
|
||||
def test_get_zero_with_no_input_value(self, cls, strict):
|
||||
"""`gf2()` and `GF2Zero()` return `zero`."""
|
||||
result = cls(strict=strict)
|
||||
assert result is zero
|
||||
|
||||
|
||||
class TestGenericBehavior:
|
||||
"""Test the classes behind `one` and `zero`."""
|
||||
|
||||
def test_cannot_instantiate_base_class_alone(self, monkeypatch):
|
||||
"""`GF2One` and `GF2Zero` must be instantiated before `gf2`."""
|
||||
monkeypatch.setattr(gf2, "_instances", {})
|
||||
with pytest.raises(RuntimeError, match="internal error"):
|
||||
gf2()
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("cls", [gf2, GF2One, GF2Zero])
|
||||
def test_create_singletons(self, cls):
|
||||
"""Singleton pattern: The classes always return the same instance."""
|
||||
first = cls()
|
||||
second = cls()
|
||||
assert first is second
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_sub_classes_return_objs(self, obj):
|
||||
"""`type(one)` and `type(zero)` return ...
|
||||
|
||||
the sub-classes that create `one` and `zero`.
|
||||
"""
|
||||
sub_cls = type(obj)
|
||||
assert sub_cls is not gf2
|
||||
|
||||
new_obj = sub_cls()
|
||||
assert new_obj is obj
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize(
|
||||
"type_",
|
||||
[
|
||||
numbers.Number,
|
||||
numbers.Complex,
|
||||
numbers.Real,
|
||||
numbers.Rational,
|
||||
],
|
||||
)
|
||||
def test_objs_are_numbers(self, obj, type_):
|
||||
"""`one` and `zero` are officially `Numbers`s."""
|
||||
assert isinstance(obj, type_)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("cls", [gf2, GF2One, GF2Zero])
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
"__abs__",
|
||||
"__trunc__",
|
||||
"__floor__",
|
||||
"__ceil__",
|
||||
"__round__",
|
||||
"__floordiv__",
|
||||
"__rfloordiv__",
|
||||
"__mod__",
|
||||
"__rmod__",
|
||||
"__lt__",
|
||||
"__le__",
|
||||
"numerator",
|
||||
"denominator",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("value", [1, 0])
|
||||
def test_classes_fulfill_rational_numbers_abc(
|
||||
self,
|
||||
cls,
|
||||
method,
|
||||
monkeypatch,
|
||||
value,
|
||||
):
|
||||
"""Ensure all of `numbers.Rational`'s abstact methods are implemented."""
|
||||
monkeypatch.setattr(gf2, "_instances", {})
|
||||
monkeypatch.delattr(gf2, method)
|
||||
|
||||
sub_cls = type("GF2Baby", (cls, numbers.Rational), {})
|
||||
|
||||
with pytest.raises(TypeError, match="instantiate abstract class"):
|
||||
sub_cls(value)
|
||||
|
||||
@pytest.mark.parametrize("func", [repr, str])
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_text_repr_for_objs(self, func, obj):
|
||||
"""`repr(one)` and `repr(zero)` return "one" and "zero" ...
|
||||
|
||||
... which is valid code evaluating into the objects themselves.
|
||||
|
||||
`str()` does the same as `repr()`.
|
||||
"""
|
||||
new_obj = eval(func(obj)) # noqa: S307
|
||||
assert new_obj is obj
|
||||
|
||||
@pytest.mark.parametrize("func", [repr, str])
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_text_repr_for_classes(self, func, obj):
|
||||
"""'gf2' is the text representation for all sub-classes ...
|
||||
|
||||
... which is valid code referring to the base class `gf2`.
|
||||
|
||||
`gf2()` returns `zero` if called without arguments.
|
||||
"""
|
||||
base_cls = eval(func(type(obj))) # noqa: S307
|
||||
assert base_cls is gf2
|
||||
|
||||
new_obj = base_cls()
|
||||
assert new_obj is zero
|
||||
|
||||
|
||||
class TestNumericBehavior:
|
||||
"""Test how `one` and `zero` behave like numbers."""
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
def test_make_complex(self):
|
||||
"""`one` and `zero` behave like `1 + 0j` and `0 + 0j`."""
|
||||
assert (complex(one), complex(zero)) == (1 + 0j, 0 + 0j)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
def test_make_float(self):
|
||||
"""`one` and `zero` behave like `1.0` and `0.0`."""
|
||||
assert (float(one), float(zero)) == (1.0, 0.0)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("func", [int, hash])
|
||||
def test_make_int(self, func):
|
||||
"""`one` and `zero` behave like `1` and `0`.
|
||||
|
||||
That also holds true for their hash values.
|
||||
"""
|
||||
assert (func(one), func(zero)) == (1, 0)
|
||||
|
||||
def test_make_bool(self):
|
||||
"""`one` and `zero` behave like `True` and `False`."""
|
||||
assert (bool(one), bool(zero)) == (True, False)
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_get_abs_value(self, obj):
|
||||
"""`abs(one)` and `abs(zero)` are `one` and `zero`."""
|
||||
assert abs(obj) is obj
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize("func", [math.trunc, math.floor, math.ceil, round])
|
||||
def test_round_obj(self, obj, func):
|
||||
"""`func(one)` and `func(zero)` equal `1` and `0`."""
|
||||
assert func(obj) in (1, 0)
|
||||
|
||||
if CROSS_REFERENCE:
|
||||
assert func(obj) == obj
|
||||
|
||||
def test_real_part(self):
|
||||
"""`one.real` and `zero.real` are `1` and `0`."""
|
||||
assert (one.real, zero.real) == (1, 0)
|
||||
|
||||
def test_imag_part(self):
|
||||
"""`one.imag` and `zero.imag` are `0`."""
|
||||
assert (one.imag, zero.imag) == (0, 0)
|
||||
|
||||
def test_conjugate(self):
|
||||
"""`one.conjugate()` and `zero.conjugate()` are `1 + 0j` and `0 + 0j`."""
|
||||
assert (one.conjugate(), zero.conjugate()) == (1 + 0j, 0 + 0j)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
def test_one_as_fraction(self):
|
||||
"""`one.numerator / one.denominator` equals `1`."""
|
||||
assert (one.numerator, one.denominator) == (1, 1)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
def test_zero_as_fraction(self):
|
||||
"""`one.numerator / one.denominator` equals `0`."""
|
||||
assert (zero.numerator, zero.denominator) == (0, 1)
|
||||
|
||||
|
||||
class TestComparison:
|
||||
"""Test `one` and `zero` interact with relational operators."""
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_equal_to_itself(self, obj):
|
||||
"""`one` and `zero` are equal to themselves."""
|
||||
assert obj == obj # noqa: PLR0124
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize(
|
||||
["first", "second"],
|
||||
[
|
||||
(one, one),
|
||||
(one, 1),
|
||||
(one, 1.0),
|
||||
(one, 1 + 0j),
|
||||
(zero, zero),
|
||||
(zero, 0),
|
||||
(zero, 0.0),
|
||||
(zero, 0 + 0j),
|
||||
],
|
||||
)
|
||||
def test_equal_to_another(self, first, second):
|
||||
"""`one` and `zero` are equal to `1`-like and `0`-like numbers."""
|
||||
assert first == second
|
||||
assert second == first
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize(
|
||||
["first", "second"],
|
||||
[
|
||||
(one, zero),
|
||||
(one, 0),
|
||||
(one, 0.0),
|
||||
(one, 0 + 0j),
|
||||
(zero, 1),
|
||||
(zero, 1.0),
|
||||
(zero, 1 + 0j),
|
||||
],
|
||||
)
|
||||
def test_not_equal_to_another_one_or_zero_like(self, first, second):
|
||||
"""`one` and `zero` are not equal to `0`-like and `1`-like numbers."""
|
||||
assert first != second
|
||||
assert second != first
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize(
|
||||
["first", "second"],
|
||||
[
|
||||
(one, 42),
|
||||
(one, 42.0),
|
||||
(one, 42 + 0j),
|
||||
(one, 0 + 42j),
|
||||
(zero, 42),
|
||||
(zero, 42.0),
|
||||
(zero, 42 + 0j),
|
||||
(zero, 0 + 42j),
|
||||
],
|
||||
)
|
||||
def test_not_equal_to_another_non_one_like(self, first, second):
|
||||
"""`one` and `zero` are not equal to non-`1`-or-`0`-like numbers."""
|
||||
assert first != second
|
||||
assert second != first
|
||||
|
||||
@pytest.mark.parametrize("operator", [operator.gt, operator.ge])
|
||||
def test_one_greater_than_or_equal_to_zero(self, operator):
|
||||
"""`one > zero` and `one >= zero`."""
|
||||
assert operator(one, zero)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("operator", [operator.lt, operator.le])
|
||||
def test_one_not_smaller_than_or_equal_to_zero(self, operator):
|
||||
"""`not one < zero` and `not one <= zero`."""
|
||||
assert not operator(one, zero)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("operator", [operator.lt, operator.le])
|
||||
def test_zero_smaller_than_or_equal_to_one(self, operator):
|
||||
"""`zero < one` and `zero <= one`."""
|
||||
assert operator(zero, one)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("operator", [operator.gt, operator.ge])
|
||||
def test_zero_not_greater_than_or_equalt_to_one(self, operator):
|
||||
"""`not zero > one` and `not zero >= one`."""
|
||||
assert not operator(zero, one)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_obj_not_strictly_greater_than_itself(self, obj):
|
||||
"""`obj >= obj` but not `obj > obj`."""
|
||||
assert obj >= obj # noqa: PLR0124
|
||||
assert not obj > obj # noqa: PLR0124
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
def test_obj_not_strictly_smaller_than_itself(self, obj):
|
||||
"""`obj <= obj` but not `obj < obj`."""
|
||||
assert obj <= obj # noqa: PLR0124
|
||||
assert not obj < obj # noqa: PLR0124
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
[operator.gt, operator.ge, operator.lt, operator.le],
|
||||
)
|
||||
def test_compare_to_other_operand_of_wrong_type(self, obj, operator):
|
||||
"""`one` and `zero` may only interact with numbers."""
|
||||
with pytest.raises(TypeError):
|
||||
operator(obj, "abc")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
operator("abc", obj)
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
[operator.gt, operator.ge, operator.lt, operator.le],
|
||||
)
|
||||
def test_compare_to_other_operand_of_wrong_value(self, obj, operator):
|
||||
"""`one` and `zero` may only interact with `1`-like or `0`-like numbers."""
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
operator(obj, 42)
|
||||
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
operator(42, obj)
|
||||
|
||||
|
||||
class TestArithmetic:
|
||||
"""Test `one` and `zero` interact with arithmetic operators."""
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize("operator", [operator.pos, operator.neg])
|
||||
def test_make_obj_positive_or_negative(self, obj, operator):
|
||||
"""`+one` and `+zero` equal `-one` and `-zero`."""
|
||||
assert obj is operator(obj)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"objs",
|
||||
[
|
||||
(one, one, zero),
|
||||
(one, zero, one),
|
||||
(zero, zero, zero),
|
||||
(one, 1, zero),
|
||||
(one, 1.0, zero),
|
||||
(one, 1 + 0j, zero),
|
||||
(one, 0, one),
|
||||
(one, 0.0, one),
|
||||
(one, 0 + 0j, one),
|
||||
(zero, 1, one),
|
||||
(zero, 1.0, one),
|
||||
(zero, 1 + 0j, one),
|
||||
(zero, 0, zero),
|
||||
(zero, 0.0, zero),
|
||||
(zero, 0 + 0j, zero),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("operator", [operator.add, operator.sub])
|
||||
def test_addition_and_subtraction(self, objs, operator):
|
||||
"""Adding and subtracting `one` and `zero` is identical and commutative."""
|
||||
first, second, expected = objs
|
||||
|
||||
result1 = operator(first, second)
|
||||
assert result1 is expected
|
||||
|
||||
result2 = operator(second, first)
|
||||
assert result2 is expected
|
||||
|
||||
if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math
|
||||
|
||||
result3 = gf2((operator(int(abs(first)), int(abs(second))) + 2) % 2)
|
||||
assert result3 is expected
|
||||
|
||||
result4 = gf2((operator(int(abs(second)), int(abs(first))) + 2) % 2)
|
||||
assert result4 is expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["first", "second", "expected"],
|
||||
[
|
||||
(one, one, one),
|
||||
(one, zero, zero),
|
||||
(zero, zero, zero),
|
||||
(one, 1, one),
|
||||
(one, 1.0, one),
|
||||
(one, 1 + 0j, one),
|
||||
(one, 0, zero),
|
||||
(one, 0.0, zero),
|
||||
(one, 0 + 0j, zero),
|
||||
(zero, 1, zero),
|
||||
(zero, 1.0, zero),
|
||||
(zero, 1 + 0j, zero),
|
||||
(zero, 0, zero),
|
||||
(zero, 0.0, zero),
|
||||
(zero, 0 + 0j, zero),
|
||||
],
|
||||
)
|
||||
def test_multiplication(self, first, second, expected):
|
||||
"""Multiplying `one` and `zero` is commutative."""
|
||||
result1 = first * second
|
||||
assert result1 is expected
|
||||
|
||||
result2 = second * first
|
||||
assert result2 is expected
|
||||
|
||||
if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math
|
||||
|
||||
result3 = gf2(int(abs(first)) * int(abs(second)))
|
||||
assert result3 is expected
|
||||
|
||||
result4 = gf2(int(abs(second)) * int(abs(first)))
|
||||
assert result4 is expected
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize(
|
||||
"objs",
|
||||
[
|
||||
# In Python 3.9 we cannot floor-divide a `complex` number
|
||||
(one, one, one),
|
||||
(one, 1, one),
|
||||
(one, 1.0, one),
|
||||
(one, 1 + 0j, one),
|
||||
(1, one, one),
|
||||
(1.0, one, one),
|
||||
(zero, one, zero),
|
||||
(zero, 1, zero),
|
||||
(zero, 1.0, zero),
|
||||
(zero, 1 + 0j, zero),
|
||||
(0, one, zero),
|
||||
(0.0, one, zero),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
[operator.truediv, operator.floordiv],
|
||||
)
|
||||
def test_division_by_one(self, objs, operator):
|
||||
"""Division by `one`."""
|
||||
first, second, expected = objs
|
||||
|
||||
result1 = operator(first, second)
|
||||
assert result1 is expected
|
||||
|
||||
if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math
|
||||
|
||||
result2 = gf2(operator(int(abs(first)), int(abs(second))))
|
||||
assert result2 is expected
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize(
|
||||
"objs",
|
||||
[
|
||||
# In Python 3.9 we cannot modulo-divide a `complex` number
|
||||
(one, one, zero),
|
||||
(one, 1, zero),
|
||||
(one, 1.0, zero),
|
||||
(one, 1 + 0j, zero),
|
||||
(1, one, zero),
|
||||
(1.0, one, zero),
|
||||
(zero, one, zero),
|
||||
(zero, 1, zero),
|
||||
(zero, 1.0, zero),
|
||||
(zero, 1 + 0j, zero),
|
||||
(0, one, zero),
|
||||
(0.0, one, zero),
|
||||
],
|
||||
)
|
||||
def test_modulo_division_by_one(self, objs):
|
||||
"""Division by `one`."""
|
||||
first, second, expected = objs
|
||||
|
||||
result1 = first % second
|
||||
assert result1 is expected
|
||||
|
||||
if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math
|
||||
|
||||
result2 = gf2(int(abs(first)) % int(abs(second)))
|
||||
assert result2 is expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"objs",
|
||||
[
|
||||
# In Python 3.9 we cannot floor-divide a `complex` number
|
||||
(one, zero),
|
||||
(one, 0),
|
||||
(one, 0.0),
|
||||
(1, zero),
|
||||
(1.0, zero),
|
||||
(zero, zero),
|
||||
(zero, 0),
|
||||
(zero, 0.0),
|
||||
(0, zero),
|
||||
(0.0, zero),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
[operator.truediv, operator.floordiv, operator.mod],
|
||||
)
|
||||
def test_division_by_zero(self, objs, operator):
|
||||
"""Division by `zero` raises `ZeroDivisionError`."""
|
||||
first, second = objs
|
||||
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
operator(first, second)
|
||||
|
||||
if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math
|
||||
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
operator(int(abs(first)), int(abs(second)))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"objs",
|
||||
[
|
||||
(one, one, one),
|
||||
(one, 1, one),
|
||||
(one, 1.0, one),
|
||||
(one, 1 + 0j, one),
|
||||
(1, one, one),
|
||||
(1.0, one, one),
|
||||
(1 + 0j, one, one),
|
||||
(zero, one, zero),
|
||||
(zero, 1, zero),
|
||||
(zero, 1.0, zero),
|
||||
(zero, 1 + 0j, zero),
|
||||
(0, one, zero),
|
||||
(0.0, one, zero),
|
||||
(0 + 0j, one, zero),
|
||||
(one, zero, one),
|
||||
(one, 0, one),
|
||||
(one, 0.0, one),
|
||||
(one, 0 + 0j, one),
|
||||
(1, zero, one),
|
||||
(1.0, zero, one),
|
||||
(1 + 0j, zero, one),
|
||||
(zero, zero, one),
|
||||
(zero, 0, one),
|
||||
(zero, 0.0, one),
|
||||
(zero, 0 + 0j, one),
|
||||
(0, zero, one),
|
||||
(0.0, zero, one),
|
||||
(0 + 0j, zero, one),
|
||||
],
|
||||
)
|
||||
def test_to_the_power_of(self, objs):
|
||||
"""Exponentiation with `one` and `zero`."""
|
||||
first, second, expected = objs
|
||||
|
||||
result1 = first**second
|
||||
assert result1 is expected
|
||||
|
||||
if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math
|
||||
|
||||
result2 = gf2(int(abs(first)) ** int(abs(second)))
|
||||
assert result2 is expected
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
[
|
||||
operator.add,
|
||||
operator.mul,
|
||||
operator.truediv,
|
||||
operator.floordiv,
|
||||
operator.mod,
|
||||
operator.pow,
|
||||
],
|
||||
)
|
||||
def test_other_operand_of_wrong_type(self, obj, operator):
|
||||
"""`one` and `zero` may only interact with numbers."""
|
||||
# Cannot use a `str` like `"abc"` as then `%` means string formatting
|
||||
with pytest.raises(TypeError):
|
||||
operator(obj, ("a", "b", "c"))
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
operator(("a", "b", "c"), obj)
|
||||
|
||||
@pytest.mark.parametrize("obj", [one, zero])
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
[
|
||||
operator.add,
|
||||
operator.mul,
|
||||
operator.truediv,
|
||||
operator.floordiv,
|
||||
operator.mod,
|
||||
operator.pow,
|
||||
],
|
||||
)
|
||||
def test_other_operand_of_wrong_value(self, obj, operator):
|
||||
"""`one` and `zero` may only interact with `1`-like or `0`-like numbers."""
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
operator(obj, 42)
|
||||
|
||||
with pytest.raises(ValueError, match="`1`-like or `0`-like"):
|
||||
operator(42, obj)
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.skipif(
|
||||
not sys.version_info < (3, 11),
|
||||
reason='"typing-extensions" are installed to support Python 3.9 & 3.10',
|
||||
)
|
||||
def test_can_import_typing_extensions():
|
||||
"""For Python versions 3.11+ we do not need the "typing-extensions"."""
|
||||
package = importlib.import_module("lalib.elements.galois")
|
||||
importlib.reload(package)
|
||||
|
||||
assert package.Self is not None
|
||||
|
||||
|
||||
@pytest.mark.sanity_test
|
||||
def test_thresholds():
|
||||
"""Sanity check for the thresholds used in the tests below."""
|
||||
assert utils.WITHIN_THRESHOLD < utils.DEFAULT_THRESHOLD < utils.NOT_WITHIN_THRESHOLD
|
1
tests/fields/__init__.py
Normal file
1
tests/fields/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the `lalib.fields` sub-package."""
|
96
tests/fields/test_axioms.py
Normal file
96
tests/fields/test_axioms.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
"""Ensure all `Field`s fulfill the axioms from math.
|
||||
|
||||
Source: https://en.wikipedia.org/wiki/Field_(mathematics)#Classic_definition
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import operator
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
# None of the test cases below contributes towards higher coverage
|
||||
pytestmark = pytest.mark.integration_test
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
class TestAllFieldsManyTimes:
|
||||
"""Run the tests many times for all `field`s."""
|
||||
|
||||
@pytest.mark.parametrize("opr", [operator.add, operator.mul])
|
||||
def test_associativity(self, field, opr):
|
||||
"""`a + (b + c) == (a + b) + c` ...
|
||||
|
||||
... and `a * (b * c) == (a * b) * c`.
|
||||
"""
|
||||
a, b, c = field.random(), field.random(), field.random()
|
||||
|
||||
left = opr(a, opr(b, c))
|
||||
right = opr(opr(a, b), c)
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
@pytest.mark.parametrize("opr", [operator.add, operator.mul])
|
||||
def test_commutativity(self, field, opr):
|
||||
"""`a + b == b + a` ...
|
||||
|
||||
... and `a * b == b * a`.
|
||||
"""
|
||||
a, b = field.random(), field.random()
|
||||
|
||||
left = opr(a, b)
|
||||
right = opr(b, a)
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_additive_identity(self, field):
|
||||
"""`a + 0 == a`."""
|
||||
a = field.random()
|
||||
|
||||
left = a + field.zero
|
||||
right = a
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_multiplicative_identity(self, field):
|
||||
"""`a * 1 == a`."""
|
||||
a = field.random()
|
||||
|
||||
left = a * field.one
|
||||
right = a
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_additive_inverse(self, field):
|
||||
"""`a + (-a) == 0`."""
|
||||
a = field.random()
|
||||
|
||||
left = a + (-a)
|
||||
right = field.zero
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_multiplicative_inverse(self, field):
|
||||
"""`a * (1 / a) == 1`."""
|
||||
a = field.random()
|
||||
|
||||
# Realistically, `ZeroDivisionError` only occurs for `GF2`
|
||||
# => With a high enough `utils.N_RANDOM_DRAWS`
|
||||
# this test case is also `assert`ed for `GF2`
|
||||
with contextlib.suppress(ZeroDivisionError):
|
||||
left = a * (field.one / a)
|
||||
right = field.one
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_distributivity(self, field):
|
||||
"""`a * (b + c) == (a * b) + (a * c)`."""
|
||||
a, b, c = field.random(), field.random(), field.random()
|
||||
|
||||
left = a * (b + c)
|
||||
right = (a * b) + (a * c)
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
267
tests/fields/test_base.py
Normal file
267
tests/fields/test_base.py
Normal file
|
@ -0,0 +1,267 @@
|
|||
"""Generic tests for all `lalib.fields.*.Field`s.
|
||||
|
||||
The abstract base class `lalib.fields.base.Field`
|
||||
defines generic behavior that all concrete `Field`s
|
||||
in the `lalib.fields` sub-package must implement.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
class TestGenericClassBehavior:
|
||||
"""Generic `Field` behavior."""
|
||||
|
||||
def test_create_singletons(self, field):
|
||||
"""All `field`s so far are singletons."""
|
||||
cls = type(field)
|
||||
new_field = cls()
|
||||
|
||||
assert new_field is field
|
||||
|
||||
@pytest.mark.parametrize("func", [repr, str])
|
||||
def test_text_repr(self, field, func):
|
||||
"""The text representations behave like Python literals."""
|
||||
new_field = eval(func(field), fields.__dict__) # noqa: S307
|
||||
|
||||
assert new_field is field
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test `Field.cast()` and `Field.validate()`.
|
||||
|
||||
Every `field` must be able to tell if a given `value` is
|
||||
an element of the `field`, and, if so, `.cast()` it as such.
|
||||
"""
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.NUMBERS)
|
||||
def test_number_is_field_element(self, field, value):
|
||||
"""Common numbers are typically `field` elements.
|
||||
|
||||
This is not true for `GF2`, which, by default,
|
||||
only accepts `1`-like and `0`-like numbers.
|
||||
"""
|
||||
utils.is_field_element(field, value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ONES_N_ZEROS)
|
||||
def test_one_and_zero_number_is_field_element(self, field, value):
|
||||
"""`1`-like and `0`-like numbers are always `field` elements."""
|
||||
utils.is_field_element(field, value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", ["abc", (1, 2, 3)])
|
||||
def test_non_numeric_value_is_not_field_element(self, field, value):
|
||||
"""Values of non-numeric data types are typically not `field` elements."""
|
||||
utils.is_not_field_element(field, value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_number_is_not_field_element(self, field, pre_value):
|
||||
"""For now, we only allow finite numbers as `field` elements.
|
||||
|
||||
Notes:
|
||||
- `Q._cast_func()` cannot handle non-finite `value`s
|
||||
and raises an `OverflowError` or `ValueError`
|
||||
=> `Field.cast()` catches these errors
|
||||
and (re-)raises a `ValueError` instead
|
||||
=> no need to define a specific `._post_cast_filter()`
|
||||
- `R._cast_func()` and `C._cast_func()`
|
||||
handle non-finite `value`s without any complaints
|
||||
=> using a `._post_cast_filter()`, we don't allow
|
||||
non-finite but castable `value`s to be `field` elements
|
||||
- `GF2._cast_func()` handles non-finite `value`s
|
||||
by raising a `ValueError` already
|
||||
=> `Field.cast()` re-raises it with an adapted message
|
||||
=> no need to define a specific `._post_cast_filter()`
|
||||
"""
|
||||
value = float(pre_value)
|
||||
utils.is_not_field_element(field, value)
|
||||
|
||||
|
||||
class TestDTypes:
|
||||
"""Test the `Field.dtype` property."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_field_dtype(self, field):
|
||||
"""`field.dtype` must be a `type`."""
|
||||
assert isinstance(field.dtype, type)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_element_is_instance_of_field_dtype(self, field):
|
||||
"""Elements are an instance of `field.dtype`."""
|
||||
element = field.random()
|
||||
|
||||
assert isinstance(element, field.dtype)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_element_dtype_is_subclass_of_field_dtype(self, field):
|
||||
"""Elements may have a more specific `.dtype` than their `field.dtype`."""
|
||||
element = field.random()
|
||||
dtype = type(element)
|
||||
|
||||
assert issubclass(dtype, field.dtype)
|
||||
|
||||
|
||||
class TestIsZero:
|
||||
"""Test `Field.zero` & `Field.is_zero()`."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ZEROS)
|
||||
def test_is_exactly_zero(self, field, value):
|
||||
"""`value` is equal to `field.zero`."""
|
||||
assert field.zero == value
|
||||
assert field.is_zero(value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_is_almost_zero(self, field):
|
||||
"""`value` is within an acceptable threshold of `field.zero`."""
|
||||
value = 0.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.zero, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert field.is_zero(value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
def test_is_slightly_not_zero(self, field):
|
||||
"""`value` is not within an acceptable threshold of `field.zero`."""
|
||||
value = 0.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.zero, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not field.is_zero(value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ONES)
|
||||
def test_is_not_zero(self, field, value):
|
||||
"""`value` is not equal to `field.zero`."""
|
||||
assert field.zero != value
|
||||
assert not field.is_zero(value)
|
||||
|
||||
|
||||
class TestIsOne:
|
||||
"""Test `Field.one` & `Field.is_one()`."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ONES)
|
||||
def test_is_exactly_one(self, field, value):
|
||||
"""`value` is equal to `field.one`."""
|
||||
assert field.one == value
|
||||
assert field.is_one(value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_is_almost_one(self, field):
|
||||
"""`value` is within an acceptable threshold of `field.one`."""
|
||||
value = 1.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.one, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert field.is_one(value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
def test_is_slightly_not_one(self, field):
|
||||
"""`value` is not within an acceptable threshold of `field.one`."""
|
||||
value = 1.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.one, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not field.is_one(value)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ZEROS)
|
||||
def test_is_not_one(self, field, value):
|
||||
"""`value` is not equal to `field.one`."""
|
||||
assert field.one != value
|
||||
assert not field.is_one(value)
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
class TestDrawRandomFieldElement:
|
||||
"""Test `Field.random()`."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_draw_element_with_default_bounds(self, field):
|
||||
"""Draw a random element from the `field`, ...
|
||||
|
||||
... within the `field`'s default bounds.
|
||||
|
||||
Here, the default bounds come from the default arguments.
|
||||
"""
|
||||
element = field.random()
|
||||
|
||||
assert field.validate(element)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_draw_element_with_default_bounds_set_to_none(self, field):
|
||||
"""Draw a random element from the `field`, ...
|
||||
|
||||
... within the `field`'s default bounds.
|
||||
|
||||
If no default arguments are defined in `field.random()`,
|
||||
the internal `Field._get_bounds()` method provides them.
|
||||
"""
|
||||
element = field.random(lower=None, upper=None)
|
||||
|
||||
assert field.validate(element)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
def test_draw_element_with_custom_bounds(self, field):
|
||||
"""Draw a random element from the `field` ...
|
||||
|
||||
... within the bounds passed in as arguments.
|
||||
|
||||
For `GF2`, this only works in non-`strict` mode.
|
||||
"""
|
||||
lower = 200 * random.random() - 100 # noqa: S311
|
||||
upper = 200 * random.random() - 100 # noqa: S311
|
||||
|
||||
# `field.random()` sorts the bounds internally
|
||||
# => test both directions
|
||||
element1 = field.random(lower=lower, upper=upper)
|
||||
element2 = field.random(lower=upper, upper=lower)
|
||||
|
||||
assert field.validate(element1)
|
||||
assert field.validate(element2)
|
||||
|
||||
# Done implicitly in `field.random()` above
|
||||
lower, upper = field.cast(lower), field.cast(upper)
|
||||
|
||||
# Not all data types behind the `Field._cast_func()`
|
||||
# support sorting the numbers (e.g., `complex`)
|
||||
try:
|
||||
swap = upper < lower
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
if swap:
|
||||
lower, upper = upper, lower
|
||||
|
||||
assert lower <= element1 <= upper
|
||||
assert lower <= element2 <= upper
|
||||
|
||||
|
||||
@pytest.mark.sanity_test
|
||||
def test_numbers():
|
||||
"""We use `0`, `1`, `+42`, and `-42` in different data types."""
|
||||
unique_one_and_zero = {int(n) for n in utils.ONES_N_ZEROS}
|
||||
unique_non_one_and_zero = {int(n) for n in utils.NON_ONES_N_ZEROS}
|
||||
unique_numbers = {int(n) for n in utils.NUMBERS}
|
||||
|
||||
assert unique_one_and_zero == {0, 1}
|
||||
assert unique_non_one_and_zero == {+42, -42}
|
||||
assert unique_numbers == {0, 1, +42, -42}
|
100
tests/fields/test_complex.py
Normal file
100
tests/fields/test_complex.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
"""Tests for the `lalib.fields.complex_.ComplexField` only."""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
# None of the test cases below contributes towards higher coverage
|
||||
pytestmark = pytest.mark.overlapping_test
|
||||
|
||||
|
||||
C = fields.C
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test specifics for `C.cast()` and `C.validate()`."""
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [1, 0, +42, -42])
|
||||
def test_complex_number_is_field_element(self, pre_value):
|
||||
"""`C` must be able to process `complex` numbers."""
|
||||
value = complex(pre_value, 0)
|
||||
utils.is_field_element(C, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_complex_number_is_not_field_element(self, pre_value):
|
||||
"""For now, we only allow finite numbers as field elements.
|
||||
|
||||
This also holds true for `complex` numbers
|
||||
with a non-finite `.real` part.
|
||||
"""
|
||||
value = complex(pre_value)
|
||||
utils.is_not_field_element(C, value)
|
||||
|
||||
|
||||
class TestIsZero:
|
||||
"""Test specifics for `C.zero` and `C.is_zero()`."""
|
||||
|
||||
def test_is_almost_zero(self):
|
||||
"""`value` is within an acceptable threshold of `C.zero`."""
|
||||
value = 0.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.zero, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert C.is_zero(value)
|
||||
|
||||
def test_is_slightly_not_zero(self):
|
||||
"""`value` is not within an acceptable threshold of `C.zero`."""
|
||||
value = 0.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.zero, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not C.is_zero(value)
|
||||
|
||||
|
||||
class TestIsOne:
|
||||
"""Test specifics for `C.one` and `C.is_one()`."""
|
||||
|
||||
def test_is_almost_one(self):
|
||||
"""`value` is within an acceptable threshold of `C.one`."""
|
||||
value = 1.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.one, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert C.is_one(value)
|
||||
|
||||
def test_is_slightly_not_one(self):
|
||||
"""`value` is not within an acceptable threshold of `C.one`."""
|
||||
value = 1.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.one, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not C.is_one(value)
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
class TestDrawRandomFieldElement:
|
||||
"""Test specifics for `C.random()`."""
|
||||
|
||||
def test_draw_elements_with_custom_bounds(self):
|
||||
"""Draw a random element from `C` ...
|
||||
|
||||
... within the bounds passed in as arguments.
|
||||
|
||||
For `C`, the bounds are interpreted in a 2D fashion.
|
||||
"""
|
||||
lower = complex(
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
)
|
||||
upper = complex(
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
)
|
||||
|
||||
element = C.random(lower=lower, upper=upper)
|
||||
|
||||
l_r, u_r = min(lower.real, upper.real), max(lower.real, upper.real)
|
||||
l_i, u_i = min(lower.imag, upper.imag), max(lower.imag, upper.imag)
|
||||
|
||||
assert l_r <= element.real <= u_r
|
||||
assert l_i <= element.imag <= u_i
|
145
tests/fields/test_galois.py
Normal file
145
tests/fields/test_galois.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
"""Tests for the `lalib.fields.galois.GaloisField2` only."""
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
# None of the test cases below contributes towards higher coverage
|
||||
pytestmark = pytest.mark.overlapping_test
|
||||
|
||||
|
||||
GF2 = fields.GF2
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test specifics for `GF2.cast()` and `GF2.validate()`."""
|
||||
|
||||
@pytest.mark.parametrize("value", utils.NUMBERS)
|
||||
def test_number_is_field_element(self, value):
|
||||
"""Common numbers are always `GF2` elements in non-`strict` mode."""
|
||||
left = GF2.cast(value, strict=False)
|
||||
right = bool(value)
|
||||
|
||||
assert left == right
|
||||
assert GF2.validate(value, strict=False)
|
||||
|
||||
@pytest.mark.parametrize("value", utils.ONES_N_ZEROS)
|
||||
def test_one_and_zero_number_is_field_element(self, value):
|
||||
"""`1`-like and `0`-like `value`s are `GF2` elements."""
|
||||
utils.is_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [1, 0])
|
||||
def test_one_or_zero_like_complex_number_is_field_element(self, pre_value):
|
||||
"""`GF2` can process `complex` numbers."""
|
||||
value = complex(pre_value, 0)
|
||||
utils.is_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [+42, -42])
|
||||
def test_non_one_or_zero_like_complex_number_is_not_field_element(self, pre_value):
|
||||
"""`GF2` can process `complex` numbers ...
|
||||
|
||||
... but they must be `one`-like or `zero`-like
|
||||
to become a `GF2` element.
|
||||
"""
|
||||
value = complex(pre_value, 0)
|
||||
utils.is_not_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [+42, -42])
|
||||
def test_non_one_or_zero_like_complex_number_is_field_element(self, pre_value):
|
||||
"""`GF2` can process all `complex` numbers in non-`strict` mode."""
|
||||
value = complex(pre_value, 0)
|
||||
|
||||
left = GF2.cast(value, strict=False)
|
||||
right = bool(value)
|
||||
|
||||
assert left == right
|
||||
assert GF2.validate(value, strict=False)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_complex_number_is_not_field_element(self, pre_value):
|
||||
"""For now, we only allow finite numbers as field elements.
|
||||
|
||||
This also holds true for `complex` numbers
|
||||
with a non-finite `.real` part.
|
||||
"""
|
||||
value = complex(pre_value)
|
||||
utils.is_not_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "0"])
|
||||
def test_one_or_zero_like_numeric_str_is_field_element(self, value):
|
||||
"""`GF2` can process `str`ings resemling `1`s and `0`s."""
|
||||
utils.is_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("value", ["+42", "-42"])
|
||||
def test_non_one_or_zero_like_numeric_str_is_not_field_element(self, value):
|
||||
"""`GF2` can process `str`ings resembling numbers ...
|
||||
|
||||
... but they must be `1`-like or `0`-like.
|
||||
"""
|
||||
utils.is_not_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("value", ["+42", "-42"])
|
||||
def test_non_one_or_zero_like_numeric_str_is_field_element(self, value):
|
||||
"""`GF2` can process `str`ings resemling any number in non-`strict` mode."""
|
||||
left = GF2.cast(value, strict=False)
|
||||
right = bool(float(value))
|
||||
|
||||
assert left == right
|
||||
assert GF2.validate(value, strict=False)
|
||||
|
||||
@pytest.mark.parametrize("value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_numeric_str_is_not_field_element(self, value):
|
||||
"""`GF2` can process `str`ings resemling numbers ...
|
||||
|
||||
... but they must represent finite numbers.
|
||||
"""
|
||||
utils.is_not_field_element(GF2, value)
|
||||
|
||||
|
||||
class TestIsZero:
|
||||
"""Test specifics for `GF2.zero` and `GF2.is_zero()`."""
|
||||
|
||||
def test_is_slightly_not_zero(self):
|
||||
"""`value` is not within an acceptable threshold of `GF2.zero`."""
|
||||
value = 0.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert GF2.zero != value
|
||||
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
GF2.is_zero(value)
|
||||
|
||||
|
||||
class TestIsOne:
|
||||
"""Test specifics for `GF2.one` and `GF2.is_one()`."""
|
||||
|
||||
def test_is_slightly_not_one(self):
|
||||
"""`value` is not within an acceptable threshold of `GF2.one`."""
|
||||
value = 1.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert GF2.one != value
|
||||
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
GF2.is_one(value)
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
class TestDrawRandomFieldElement:
|
||||
"""Test specifics for `GF2.random()`."""
|
||||
|
||||
@pytest.mark.parametrize("bounds", itertools.product([0, 1], repeat=2))
|
||||
def test_draw_element_with_custom_bounds(self, bounds):
|
||||
"""Draw a random element from `GF2` in non-`strict` mode ...
|
||||
|
||||
... within the bounds passed in as arguments.
|
||||
"""
|
||||
lower, upper = bounds
|
||||
element = GF2.random(lower=lower, upper=upper)
|
||||
|
||||
if upper < lower:
|
||||
lower, upper = upper, lower
|
||||
|
||||
assert lower <= element <= upper
|
33
tests/fields/test_rational.py
Normal file
33
tests/fields/test_rational.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
"""Tests for the `lalib.fields.rational.RationalField` only."""
|
||||
|
||||
import fractions
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
|
||||
|
||||
# None of the test cases below contributes towards higher coverage
|
||||
pytestmark = pytest.mark.overlapping_test
|
||||
|
||||
|
||||
Q = fields.Q
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test specifics for `Q.cast()` and `Q.validate()`."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["1", "0", "1/1", "0/1", "+42", "-42", "+42/1", "-42/1"],
|
||||
)
|
||||
def test_str_is_field_element(self, value):
|
||||
"""`fractions.Fraction()` also accepts `str`ings.
|
||||
|
||||
Source: https://docs.python.org/3/library/fractions.html#fractions.Fraction
|
||||
"""
|
||||
left = Q.cast(value)
|
||||
right = fractions.Fraction(value)
|
||||
|
||||
assert left == right
|
||||
assert Q.validate(value)
|
75
tests/fields/utils.py
Normal file
75
tests/fields/utils.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
"""Utilities to test the `lalib.fields` sub-package."""
|
||||
|
||||
import decimal
|
||||
import fractions
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import elements
|
||||
from lalib import fields
|
||||
from tests import utils as root_utils
|
||||
|
||||
|
||||
ALL_FIELDS = (fields.Q, fields.R, fields.C, fields.GF2)
|
||||
NON_10_FIELDS = (fields.Q, fields.R, fields.C)
|
||||
|
||||
ONES = (
|
||||
1,
|
||||
1.0,
|
||||
fractions.Fraction(1, 1),
|
||||
decimal.Decimal("1.0"),
|
||||
elements.one,
|
||||
True,
|
||||
)
|
||||
|
||||
ZEROS = (
|
||||
0,
|
||||
0.0,
|
||||
fractions.Fraction(0, 1),
|
||||
decimal.Decimal("+0.0"),
|
||||
decimal.Decimal("-0.0"),
|
||||
elements.zero,
|
||||
False,
|
||||
)
|
||||
|
||||
ONES_N_ZEROS = ONES + ZEROS
|
||||
|
||||
NON_ONES_N_ZEROS = (
|
||||
+42,
|
||||
+42.0,
|
||||
fractions.Fraction(+42, 1),
|
||||
decimal.Decimal("+42.0"),
|
||||
-42,
|
||||
-42.0,
|
||||
fractions.Fraction(-42, 1),
|
||||
decimal.Decimal("-42.0"),
|
||||
)
|
||||
|
||||
NUMBERS = ONES_N_ZEROS + NON_ONES_N_ZEROS
|
||||
|
||||
DEFAULT_THRESHOLD = root_utils.DEFAULT_THRESHOLD
|
||||
WITHIN_THRESHOLD = root_utils.WITHIN_THRESHOLD
|
||||
NOT_WITHIN_THRESHOLD = root_utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
N_RANDOM_DRAWS = os.environ.get("N_RANDOM_DRAWS") or 1
|
||||
|
||||
|
||||
def is_field_element(field, value):
|
||||
"""Utility method to avoid redundant logic in tests."""
|
||||
element = field.cast(value)
|
||||
|
||||
assert element == value
|
||||
assert field.validate(value)
|
||||
|
||||
|
||||
def is_not_field_element(field, value):
|
||||
"""Utility method to avoid redundant logic in tests."""
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
field.cast(value)
|
||||
|
||||
assert not field.validate(value)
|
||||
assert not field.validate(value, silent=True)
|
||||
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
field.validate(value, silent=False)
|
|
@ -10,10 +10,20 @@ import pytest
|
|||
import xdoctest
|
||||
|
||||
|
||||
@pytest.mark.integration_test
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
[
|
||||
"lalib",
|
||||
"lalib.domains",
|
||||
"lalib.elements",
|
||||
"lalib.elements.galois",
|
||||
"lalib.fields",
|
||||
"lalib.fields.base",
|
||||
"lalib.fields.complex_",
|
||||
"lalib.fields.galois",
|
||||
"lalib.fields.rational",
|
||||
"lalib.fields.real",
|
||||
],
|
||||
)
|
||||
def test_docstrings(module):
|
||||
|
|
212
tests/test_domains.py
Normal file
212
tests/test_domains.py
Normal file
|
@ -0,0 +1,212 @@
|
|||
"""Tests for `lalib.domains.Domain`."""
|
||||
|
||||
import os
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib.domains import Domain
|
||||
|
||||
|
||||
CROSS_REFERENCE = not os.environ.get("NO_CROSS_REFERENCE")
|
||||
|
||||
|
||||
NUMERIC_LABELS = (1, 42) # always interpreted in a canonical way
|
||||
|
||||
CANONICAL_ITERABLE_LABELS = tuple(range(number) for number in NUMERIC_LABELS)
|
||||
NON_CANONICAL_ITERABLE_LABELS = (
|
||||
range(1, 42),
|
||||
[-42, 0, +42],
|
||||
"abc",
|
||||
("x", "y", "z"),
|
||||
)
|
||||
ITERABLE_LABELS = CANONICAL_ITERABLE_LABELS + NON_CANONICAL_ITERABLE_LABELS
|
||||
|
||||
CANONICAL_MAPPING_LABELS = (
|
||||
{0: 123},
|
||||
{0: 123, 1: 456},
|
||||
)
|
||||
NON_CANONICAL_MAPPING_LABELS = (
|
||||
{0: 123, 42: 456},
|
||||
{"a": 123, "b": 456},
|
||||
)
|
||||
MAPPING_LABELS = CANONICAL_MAPPING_LABELS + NON_CANONICAL_MAPPING_LABELS
|
||||
|
||||
CANONICAL_LABELS = (
|
||||
*NUMERIC_LABELS,
|
||||
*CANONICAL_ITERABLE_LABELS,
|
||||
*CANONICAL_MAPPING_LABELS,
|
||||
)
|
||||
NON_CANONICAL_LABELS = (
|
||||
*NON_CANONICAL_ITERABLE_LABELS,
|
||||
*NON_CANONICAL_MAPPING_LABELS,
|
||||
)
|
||||
|
||||
ALL_LABELS = CANONICAL_LABELS + NON_CANONICAL_LABELS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def domain(request):
|
||||
"""A `Domain` object."""
|
||||
return Domain(request.param)
|
||||
|
||||
|
||||
class TestDomainInstantiation:
|
||||
"""Test `Domain.__new__()` with good inputs."""
|
||||
|
||||
@pytest.mark.parametrize("domain", ALL_LABELS, indirect=True)
|
||||
def test_from_domain(self, domain):
|
||||
"""`Domain` object passed into `Domain()` is simply returned."""
|
||||
new_domain = Domain(domain)
|
||||
|
||||
assert new_domain == domain
|
||||
assert new_domain is domain
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("number", NUMERIC_LABELS)
|
||||
def test_from_integer(self, number):
|
||||
"""Positive `int`eger passed into `Domain()` creates canonical `Domain`.
|
||||
|
||||
This is a convenience feature.
|
||||
"""
|
||||
domain = Domain(number)
|
||||
expected = set(range(number))
|
||||
|
||||
assert domain == expected
|
||||
|
||||
if CROSS_REFERENCE:
|
||||
assert domain.is_canonical
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("mapping", MAPPING_LABELS)
|
||||
def test_from_mapping(self, mapping):
|
||||
"""Create `Domain` from various mapping objects."""
|
||||
domain = Domain(mapping)
|
||||
expected = mapping.keys()
|
||||
|
||||
assert domain == expected
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("iterable", ITERABLE_LABELS)
|
||||
def test_from_iterable(self, iterable):
|
||||
"""Create `Domain` from various iterable objects."""
|
||||
domain = Domain(iterable)
|
||||
expected = set(iterable)
|
||||
|
||||
assert domain == expected
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
@pytest.mark.parametrize("number", NUMERIC_LABELS)
|
||||
def test_from_iterator_yielding_canonical_labels(self, number):
|
||||
"""`Domain()` can consume iterators: Providing canonical `labels`."""
|
||||
|
||||
def generator_factory():
|
||||
"""Yield `0`, `1`, ... `number - 1`."""
|
||||
yield from range(number)
|
||||
|
||||
generator = generator_factory()
|
||||
domain = Domain(generator)
|
||||
expected = set(range(number))
|
||||
|
||||
assert domain == expected
|
||||
|
||||
if CROSS_REFERENCE:
|
||||
assert domain.is_canonical
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
def test_from_iterator_yielding_non_canonical_labels(self):
|
||||
"""`Domain()` can consume iterators: Providing non-canonical `labels`."""
|
||||
|
||||
def generator_factory(p_skipped=0.5):
|
||||
"""Yield `0`, `1`, ..., `100` with missing values."""
|
||||
for i in range(100):
|
||||
if random.random() > p_skipped: # noqa: S311
|
||||
yield i
|
||||
|
||||
generator = generator_factory()
|
||||
domain = Domain(generator)
|
||||
|
||||
assert domain is not None
|
||||
|
||||
if CROSS_REFERENCE:
|
||||
assert not domain.is_canonical
|
||||
|
||||
@pytest.mark.parametrize("domain", CANONICAL_LABELS, indirect=True)
|
||||
def test_from_canonical_repr(self, domain):
|
||||
"""`repr(domain)` is of the form "Domain(integer)"."""
|
||||
new_domain = eval(repr(domain)) # noqa: S307
|
||||
|
||||
assert new_domain == domain
|
||||
|
||||
@pytest.mark.parametrize("domain", NON_CANONICAL_LABELS, indirect=True)
|
||||
def test_from_non_canonical_repr(self, domain):
|
||||
"""`repr(domain)` is of the form "Domain({label1, label2, ...})"."""
|
||||
new_domain = eval(repr(domain)) # noqa: S307
|
||||
|
||||
assert new_domain == domain
|
||||
|
||||
|
||||
class TestFailedDomainInstantiation:
|
||||
"""Test `Domain.__new__()` with bad inputs."""
|
||||
|
||||
def test_wrong_type(self):
|
||||
"""Cannot create `Domain` from non-numeric or non-iterable object."""
|
||||
with pytest.raises(TypeError):
|
||||
Domain(object())
|
||||
|
||||
@pytest.mark.parametrize("number", [-42, 0, 4.2])
|
||||
def test_from_non_positive_integer(self, number):
|
||||
"""Non-positive `int`egers passed into `Domain()` do not work."""
|
||||
with pytest.raises(ValueError, match="positive integer"):
|
||||
Domain(number)
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
def test_from_empty_mapping(self):
|
||||
"""Cannot create `Domain` from empty mapping objects."""
|
||||
empty_dict = {}
|
||||
|
||||
with pytest.raises(ValueError, match="at least one label"):
|
||||
Domain(empty_dict)
|
||||
|
||||
@pytest.mark.parametrize("iterable_type", [tuple, list, set])
|
||||
def test_from_empty_iterable(self, iterable_type):
|
||||
"""Cannot create `Domain` from empty iterable objects."""
|
||||
empty_iterable = iterable_type()
|
||||
|
||||
with pytest.raises(ValueError, match="at least one label"):
|
||||
Domain(empty_iterable)
|
||||
|
||||
@pytest.mark.parametrize("iterable_type", [tuple, list])
|
||||
def test_from_iterable_with_non_hashable_labels(self, iterable_type):
|
||||
"""Cannot create `Domain` with non-hashable `labels`."""
|
||||
bad_iterable = iterable_type(([1], [2], [3]))
|
||||
|
||||
with pytest.raises(TypeError, match="hashable labels"):
|
||||
Domain(bad_iterable)
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
class TestCanonicalProperty:
|
||||
"""Test `Domain.is_canonical` property."""
|
||||
|
||||
@pytest.mark.parametrize("domain", CANONICAL_LABELS, indirect=True)
|
||||
def test_is_canonical(self, domain):
|
||||
"""A `domain` with `labels` like `0`, `1`, ..."""
|
||||
assert domain.is_canonical is True
|
||||
|
||||
@pytest.mark.parametrize("domain", NON_CANONICAL_LABELS, indirect=True)
|
||||
def test_is_not_canonical(self, domain):
|
||||
"""A `domain` with `labels` unlike `0`, `1`, ..."""
|
||||
assert domain.is_canonical is False
|
||||
|
||||
# `@pytest.mark.overlapping_test` can only be used
|
||||
# because the one line of code in the `try`-block
|
||||
# is always regarded as fully covered,
|
||||
# even if an `AttributeError` is raised and excepted
|
||||
@pytest.mark.parametrize("domain", ALL_LABELS, indirect=True)
|
||||
def test_is_still_canonical_or_not(self, domain):
|
||||
"""`Domain.is_canonical` is cached."""
|
||||
result1 = domain.is_canonical
|
||||
result2 = domain.is_canonical
|
||||
|
||||
assert result1 is result2
|
32
tests/test_top_level_imports.py
Normal file
32
tests/test_top_level_imports.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
"""Test top-level imports for `lalib`."""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration_test
|
||||
@pytest.mark.parametrize(
|
||||
"path_to_package",
|
||||
[
|
||||
"lalib",
|
||||
"lalib.elements",
|
||||
"lalib.fields",
|
||||
],
|
||||
)
|
||||
def test_top_level_imports(path_to_package: str):
|
||||
"""Verify `from {path_to_package} import *` works."""
|
||||
package = importlib.import_module(path_to_package)
|
||||
|
||||
environment: dict[str, Any] = {}
|
||||
|
||||
exec("...", environment, environment) # noqa: S102
|
||||
defined_vars_before = set(environment)
|
||||
|
||||
exec(f"from {path_to_package} import *", environment, environment) # noqa: S102
|
||||
defined_vars_after = set(environment)
|
||||
|
||||
new_vars = defined_vars_after - defined_vars_before
|
||||
|
||||
assert new_vars == set(package.__all__)
|
|
@ -236,36 +236,7 @@ INVALID_NOT_SEMANTIC = (
|
|||
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
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
class VersionClassification:
|
||||
"""Classifying version identifiers.
|
||||
|
||||
|
@ -292,9 +263,9 @@ class VersionClassification:
|
|||
)
|
||||
|
||||
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
|
||||
assert parsed_version.is_devrelease
|
||||
assert parsed_version.is_prerelease
|
||||
assert not parsed_version.is_postrelease
|
||||
|
||||
return is_so_by_parts
|
||||
|
||||
|
@ -307,9 +278,9 @@ class VersionClassification:
|
|||
)
|
||||
|
||||
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
|
||||
assert not parsed_version.is_devrelease
|
||||
assert parsed_version.is_prerelease
|
||||
assert not parsed_version.is_postrelease
|
||||
|
||||
return is_so_by_parts
|
||||
|
||||
|
@ -322,9 +293,9 @@ class VersionClassification:
|
|||
)
|
||||
|
||||
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
|
||||
assert not parsed_version.is_devrelease
|
||||
assert not parsed_version.is_prerelease
|
||||
assert not parsed_version.is_postrelease
|
||||
|
||||
return is_so_by_parts
|
||||
|
||||
|
@ -337,13 +308,14 @@ class VersionClassification:
|
|||
)
|
||||
|
||||
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
|
||||
assert not parsed_version.is_devrelease
|
||||
assert not parsed_version.is_prerelease
|
||||
assert parsed_version.is_postrelease
|
||||
|
||||
return is_so_by_parts
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
class TestVersionIdentifier(VersionClassification):
|
||||
"""The versions must comply with PEP440 ...
|
||||
|
||||
|
@ -504,6 +476,7 @@ class TestVersionIdentifier(VersionClassification):
|
|||
assert parsed_version.public != unparsed_version
|
||||
|
||||
|
||||
@pytest.mark.overlapping_test
|
||||
class TestVersionIdentifierWithPattern:
|
||||
"""Test the versioning with a custom `regex` pattern."""
|
||||
|
||||
|
@ -585,3 +558,36 @@ class TestUnavailablePackageMetadata:
|
|||
with self.hide_metadata_from_package("lalib") as lalib_pkg:
|
||||
assert lalib_pkg.__pkg_name__ == "unknown"
|
||||
assert lalib_pkg.__version__ == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.sanity_test
|
||||
class TestSampleVersionData:
|
||||
"""Ensure the `VALID_*_VERSIONS` are in order."""
|
||||
|
||||
@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(self, 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(self, 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
|
||||
|
|
8
tests/utils.py
Normal file
8
tests/utils.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
"""Utilities to test the `lalib` package."""
|
||||
|
||||
from lalib import config
|
||||
|
||||
|
||||
DEFAULT_THRESHOLD = config.THRESHOLD
|
||||
WITHIN_THRESHOLD = config.THRESHOLD / 10
|
||||
NOT_WITHIN_THRESHOLD = config.THRESHOLD * 10
|
Loading…
Reference in a new issue