Compare commits

..

38 commits

Author SHA1 Message Date
aeca30b72e
Add Domain() to top-level imports for lalib
Some checks failed
audit / audit (push) Has been cancelled
docs / docs (push) Has been cancelled
lint / lint (push) Has been cancelled
test-coverage / test-coverage (push) Has been cancelled
test-docstrings / test-docstrings (push) Has been cancelled
tests / test-3.10 (push) Has been cancelled
tests / test-3.11 (push) Has been cancelled
tests / test-3.12 (push) Has been cancelled
tests / test-3.13 (push) Has been cancelled
tests / test-3.9 (push) Has been cancelled
2024-10-20 02:45:41 +02:00
4a5e316d0c
Add lalib.domains.Domain
- this class models domains from linear algebra
  and is needed for the `Vector` class to be created
- `Domain` wraps Python's built-in `frozenset` type
  + they must contain at least one label
  + as a convenience, so-called canonical `Domain`s
    (i.e., with labels `0`, `1`, ...) can be created
    by passing in a positive `int`eger to `Domain()`
- the `Domain.is_canonical` property indicates
  what kind a `Domain` is
- add unit tests for the class
- add extensive documentation for the class
2024-10-20 02:35:43 +02:00
9308633ded
Make smoke tests exit early 2024-10-20 02:24:43 +02:00
81bbd4ac0f
Make smoke tests not do cross checking
Some unit tests also contain integration-like tests
that should not be run when smoke testing
2024-10-20 02:12:11 +02:00
bfbbbd01e4
Fix missing Python version
Forgotten in commit 25c718fe6a
2024-10-20 01:55:31 +02:00
06c26192ad
Make gf2() cast values in non-strict mode
- so far, `gf2()` runs in a `strict` mode by default
  => `gf2(42)` results in a `ValueError`
- we adapt `gf2()` (a.k.a. `lalib.elements.galois.GF2Element`)
  such that it behaves more like the built-in `bool()`
  => `gf2(42)` returns `one`, like `bool(42)` returns `True`
- further, the `GF2Element` class is adjusted such that
  `one` and `zero` assume their counterparts to be
  `1`-like or `0`-like objects during binary operations;
  other values result in an error
  => for example, `one + 1` works but `one + 42` does not
  => so, when used in binary operations, `one` and `zero`
     continue to cast their counterparts in `strict` mode
- simplify the casting logic
- make `value` a positional-only argument in `gf2()`
  (like most of the built-in constructors)
- provide more usage examples in the docstrings
  clarifying when `strict` mode is used and when not
- propagate the above changes to the test suite:
  + adapt test cases with regard to the `strict` mode logic
  + add new test cases to `assert` how `gf2()`
    can process `str`ings directly
  + rename two test cases involving `complex` numbers
    to mimic the naming from the newly added test cases
2024-10-16 14:49:35 +02:00
b2f6155872
Merge branch 'fields' into 'develop'
Some checks failed
audit / audit (push) Has been cancelled
docs / docs (push) Has been cancelled
lint / lint (push) Has been cancelled
test-coverage / test-coverage (push) Has been cancelled
test-docstrings / test-docstrings (push) Has been cancelled
tests / test-3.10 (push) Has been cancelled
tests / test-3.11 (push) Has been cancelled
tests / test-3.12 (push) Has been cancelled
tests / test-3.13 (push) Has been cancelled
tests / test-3.9 (push) Has been cancelled
2024-10-16 12:01:00 +02:00
c7f076c35c
Update dependencies
- bandit (1.7.9 -> 1.7.10)
- black (24.8.0 -> 24.10.0)
- charset-normalizer (3.3.2 -> 3.4.0)
- coverage (7.6.1 -> 7.6.3)
- distlib (0.3.8 -> 0.3.9)
- markupsafe (2.1.5 -> 3.0.1)
- mypy (1.11.2 -> 1.12.0)
- pydoclint (0.5.7 -> 0.5.9)
- rich (13.8.1 -> 13.9.2)
- ruff (0.6.5 -> 0.6.9)
- sphinx (8.0.2 -> 8.1.3)
- virtualenv (20.26.5 -> 20.26.6)
2024-10-16 11:55:52 +02:00
6ddb545491
Remove unnecessary Optional
The type hints `object` and `Optional[object]`
both allow the default value `None`.

Also, this commit gets rid of ruff's "FA100" error
for the two changed lines.
2024-10-16 11:47:51 +02:00
81912f1a81
Make linters check for unused function arguments 2024-10-16 11:23:54 +02:00
25c718fe6a
Make Python 3.13 the new default 2024-10-16 01:32:14 +02:00
f952d95951
Make value a positional-only argument in gf2()
Most of Python's built-in data types behave like this as well
2024-10-15 02:17:23 +02:00
849b786e13
Add fields to top-level imports for lalib 2024-10-15 02:15:10 +02:00
6bd21ce134
Fix missing module
Forgotten in commit 153094eef5
2024-10-15 02:04:34 +02:00
7e3e67c300
Add smoke tests
- extend `pytest` with an option to run only the minimum number
  of (unit) test cases to just keep the coverage at 100%
- rationale:
  + many of the unit test cases partly overlap with
    respect to the lines of source code executed
  + also, integration tests, by definition, do not
    contribute to a higher test coverage
- implementation: mark "redundant" test cases as one of:
  + `pytest.mark.integration_test`
    => code usage from the perspective of the end user
  + `pytest.mark.overlapping_test`
    => tests not contributing to the 100% coverage
  + `pytest.mark.sanity_test`
    => tests providing confidence in the test data
- add `tests.conftest` module
  => programatically convert the above markers into
     `@pytest.mark.no_cover` and collect the non-"redundant" tests
- add nox session "test-fast" to run only the minimum
  number of (unit) test while holding coverage at 100%
- refactor some test modules
  + wrap some test cases in a class
  + move sanity tests to the end of the files
2024-10-15 01:49:32 +02:00
de740ebb5f
Unify the various *_THRESHOLDs 2024-10-14 16:36:02 +02:00
04addacb09
Add tests.utils module 2024-10-14 16:34:17 +02:00
153094eef5
Add Q, R, C, and GF2 fields
- add `lalib.fields.base.Field`, a blueprint for all concrete fields,
  providing a unified interface to be used outside of the
  `lalib.fields` sub-package
- implement `lalib.fields.complex_.ComplexField`, or `C` for short,
  the field over the complex numbers (modeled as `complex` numbers)
- implement `lalib.fields.galois.GaloisField2`, or `GF2` for short,
  the (finite) field over the two elements `one` and `zero`
  + adapt `lalib.elements.galois.GF2Element.__eq__()` to return
    `NotImplemented` instead of `False` for non-castable `other`s
    => this fixes a minor issue with `pytest.approx()`
- implement `lalib.fields.rational.RationalField`, or `Q` for short,
  the field over the rational numbers (modeled as `fractions.Fraction`s)
- implement `lalib.fields.real.RealField`, or `R` for short,
  the field over the real numbers (modeled as `float`s)
- organize top-level imports for `lalib.fields`,
  making `Q`, `R`, `C`, and `GF2` importable with
  `from lalib.fields import *`
- provide extensive unit and integration tests for the new objects:
  + test generic and common behavior in `tests.fields.test_base`
  + test specific behavior is other modules
  + test the well-known math axioms for all fields (integration tests)
  + test the new objects' docstrings
  + add "pytest-repeat" to run randomized tests many times
2024-10-14 15:17:42 +02:00
cbc1f8fd3a
Add lalib.config for library-wide settings
Also, refactor `lalib.elements.galois`
(incl. tests) to use the new settings
2024-10-14 15:02:50 +02:00
febed693b8
Make asserts more Pythonic 2024-10-14 14:38:09 +02:00
08067b3d6e
Fix pytest not respecting the singleton pattern
This is only a temporary fix!

See issue: https://github.com/webartifex/lalib/issues/1
2024-09-27 16:16:01 +02:00
3d9f990c68
Reset random.seed() before every test case 2024-09-27 16:04:37 +02:00
4c0c7887e5
Allow and unify the usage of TODOs 2024-09-27 15:29:33 +02:00
06d003b615
Hide gf2 sub-classes even more
- the `gf2` sub-classes `GF2One` and `GF2Zero` have no purpose
  other than to provide unique `help(one)` and `help(zero)` messages
- delete them at the bottom of `lalib.elements.galois`
  to prevent them from being imported at all
  (`type(one)` and `type(zero)` still appear to be the `gf2` class,
   which is a little white lie, provided by some meta class)
- differentiate between official and inofficial API in the test suite
2024-09-19 15:36:10 +02:00
348cd53767
Make lalib.elements.galois.to_gf2() a detail
- `to_gf2()` is only to be used within `GF2Element.__new__()`
  => rename it into `_to_gf2()`
- the test cases in `TestGF2ConstructorWithCastedValue`
  cover everything in `TestGF2Casting`
  => retire the redundant test cases
     and make the test suite run faster
2024-09-19 14:22:10 +02:00
917c217ca0
Rename lalib.elements.gf2.GF2 & friends
- the future (concrete) Galois `Field` implementation
  shall receive the name `GF2` (as per common math notation)
  => name conflict with the current `GF2` class
     implementing the elements of the future Galois `Field`
  => rename the current `GF2` class into `GF2Element`
- because `GF2Element` is a bit tedius to type for the end user,
  we introduce a `gf2` alias in line with the naming convention
  for the built-in data types (e.g., `int` or `float`)
  that are also used as elements of (other) `Field`s
  => name conflict with the current `lalib.elements.gf2` module
  => rename the module into `lalib.elements.galois`
- adjust the docstrings to refer to "the `gf2` type"
- adjust the top-level imports and tests
2024-09-19 12:14:20 +02:00
65de932f8d
Add lalib.fields sub-package 2024-09-18 23:42:38 +02:00
4c47ca1b17
Merge branch 'gf2-type' into 'develop' 2024-09-18 23:16:51 +02:00
ea85c73933
Update dependencies
- filelock (3.16.0 -> 3.16.1)
- identify (2.6.0 -> 2.6.1)
- idna (3.8 -> 3.10)
- platformdirs (4.3.2 -> 4.3.6)
- pytest (8.3.2 -> 8.3.3)
- rich (13.8.0 -> 13.8.1)
- ruff (0.6.4 -> 0.6.5)
- setuptools (74.1.2 -> 75.1.0)
- urllib3 (2.2.2 -> 2.2.3)
- virtualenv (20.26.4 -> 20.26.5)
2024-09-18 22:53:33 +02:00
51c73163e4
Refactor test_top_level_imports() test cases ...
... into a unified location
2024-09-18 20:05:49 +02:00
62b25f66d9
Organize top-level imports for lalib
- make `GF2`, `one`, and `zero`, defined in the `lalib.elements.gf2`
  module, available as top-level imports for the `lalib` package
  via `from lalib import *`
- provide some code snippets in the package's docstring
- test the star import
2024-09-18 18:37:54 +02:00
3cfc0db136
Organize top-level imports for lalib.elements
- make `GF2`, `one`, and `zero`, defined in the `lalib.elements.gf2`
  module, available as top-level imports in the `lalib.elements`
  sub-package via `from lalib.elements import *`
- provide some code snippets in the sub-package's docstring
- test the star import
2024-09-18 18:29:40 +02:00
3cecf0d989
Add GF2 type for Galois field elements
- add `GF2` class in the `lalib.elements` sub-package
  implementing a typical Galois field with two elements
- the singleton objects `one` and `zero` are the concrete
  instances of the `GF2` type for the end users
- besides the typical Galois arithmetic, `one` and `zero`
  behave like the built-in numbers `1` and `0`
  and implement the `numbers.Rational` interface
- add exhaustive docstrings with usage examples
- add (unit) test cases with 100% coverage
2024-09-18 18:04:35 +02:00
d405c22c90
Enforce PEP257 strictly ...
... and put docstrings for class constructors
into `.__init__()` methods

Source: https://peps.python.org/pep-0257/#multi-line-docstrings
2024-09-18 15:17:53 +02:00
d9dcea8379
Do not allow mere "pragma: no cover"s 2024-09-18 15:15:24 +02:00
9083cebe18
Fix missing empty line ...
... to make overview on sections clearer
2024-09-18 15:08:19 +02:00
d507e1f56d
Add lalib.elements sub-package 2024-09-10 11:53:17 +02:00
5d2f430893
Bump version 2024-09-10 03:55:36 +02:00
39 changed files with 3927 additions and 413 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -16,6 +16,7 @@ jobs:
3.10
3.11
3.12
3.13
architecture: x64
- run: python --version

View file

@ -14,6 +14,7 @@ jobs:
3.10
3.11
3.12
3.13
architecture: x64
- run: python --version

View file

@ -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

View file

@ -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

View file

@ -3,7 +3,7 @@ version: 2
build:
os: ubuntu-24.04
tools:
python: "3.12"
python: "3.13"
sphinx:
configuration: docs/conf.py

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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
View 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
View 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

View 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",
)

View 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

View 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
View 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__

View 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()

View 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()

View 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
View 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
View 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
View 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

View file

@ -0,0 +1 @@
"""Tests for the `lalib.elements` sub-package."""

View 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
View file

@ -0,0 +1 @@
"""Tests for the `lalib.fields` sub-package."""

View 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
View 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}

View 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
View 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

View 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
View 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)

View file

@ -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
View 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

View 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__)

View file

@ -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
View 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