diff --git a/noxfile.py b/noxfile.py index 30e9eb1..cd1da6d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,52 +1,159 @@ from pathlib import Path from tempfile import TemporaryDirectory +import os import nox ROOT = Path(__file__).parent TESTS = ROOT / "tests" PYPROJECT = ROOT / "pyproject.toml" +DOCS = ROOT / "docs" +REQUIREMENTS = dict( + docs=DOCS / "requirements.txt", + tests=TESTS / "requirements.txt", +) +REQUIREMENTS_IN = [ # this is actually ordered, as files depend on each other + path.parent / f"{path.stem}.in" for path in REQUIREMENTS.values() +] + +SUPPORTED = ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.10"] +LATEST = "3.12" nox.options.sessions = [] -def session(default=True, **kwargs): +def session(default=True, python=LATEST, **kwargs): # noqa: D103 def _session(fn): if default: nox.options.sessions.append(kwargs.get("name", fn.__name__)) - return nox.session(**kwargs)(fn) + return nox.session(python=python, **kwargs)(fn) return _session -@session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3"]) +@session(python=SUPPORTED) def tests(session): - session.install(ROOT, "-r", TESTS / "requirements.txt") - if session.posargs == ["coverage"]: + """ + Run the test suite with a corresponding Python version. + """ + session.install("-r", REQUIREMENTS["tests"]) + + if session.posargs and session.posargs[0] == "coverage": + if len(session.posargs) > 1 and session.posargs[1] == "github": + github = Path(os.environ["GITHUB_STEP_SUMMARY"]) + else: + github = None + session.install("coverage[toml]") - session.run("coverage", "run", "-m", "pytest") - session.run("coverage", "report") + session.run("coverage", "run", "-m", "pytest", TESTS) + if github is None: + session.run("coverage", "report") + else: + with github.open("a") as summary: + summary.write("### Coverage\n\n") + summary.flush() # without a flush, output seems out of order. + session.run( + "coverage", + "report", + "--format=markdown", + stdout=summary, + ) else: session.run("pytest", *session.posargs, TESTS) +@session() +def audit(session): + """ + Audit dependencies for vulnerabilities. + """ + session.install("pip-audit", ROOT) + session.run("python", "-m", "pip_audit") + + @session(tags=["build"]) def build(session): + """ + Build a distribution suitable for PyPI and check its validity. + """ session.install("build", "twine") with TemporaryDirectory() as tmpdir: session.run("python", "-m", "build", ROOT, "--outdir", tmpdir) session.run("twine", "check", "--strict", tmpdir + "/*") +@session(tags=["style"]) +def style(session): + """ + Check Python code style. + """ + session.install("ruff") + session.run("ruff", "check", ROOT) + + +@session(tags=["docs"]) +@nox.parametrize( + "builder", + [ + nox.param(name, id=name) + for name in [ + "dirhtml", + "doctest", + "linkcheck", + "man", + "spelling", + ] + ], +) +def docs(session, builder): + """ + Build the documentation using a specific Sphinx builder. + """ + session.install("-r", REQUIREMENTS["docs"]) + with TemporaryDirectory() as tmpdir_str: + tmpdir = Path(tmpdir_str) + argv = ["-n", "-T", "-W"] + if builder != "spelling": + argv += ["-q"] + posargs = session.posargs or [tmpdir / builder] + session.run( + "python", + "-m", + "sphinx", + "-b", + builder, + DOCS, + *argv, + *posargs, + ) + + +@session(tags=["docs", "style"], name="docs(style)") +def docs_style(session): + """ + Check the documentation style. + """ + session.install( + "doc8", + "pygments", + "pygments-github-lexers", + ) + session.run("python", "-m", "doc8", "--config", PYPROJECT, DOCS) + + @session(default=False) def requirements(session): + """ + Update the project's pinned requirements. Commit the result. + """ session.install("pip-tools") - for each in [TESTS / "requirements.in"]: + for each in REQUIREMENTS_IN: session.run( "pip-compile", "--resolver", "backtracking", + "--strip-extras", "-U", each.relative_to(ROOT), ) diff --git a/pyproject.toml b/pyproject.toml index 6dd0c22..69b34e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ build-backend = "maturin" [project] name = "rpds-py" description = "Python bindings to Rust's persistent data structures (rpds)" +requires-python = ">=3.8" readme = "README.rst" license = {text = "MIT"} -requires-python = ">=3.8" keywords = ["data structures", "rust", "persistent"] authors = [ {email = "Julian+rpds@GrayVines.com"}, @@ -31,6 +31,7 @@ classifiers = [ dynamic = ["version"] [project.urls] +Documentation = "https://rpds.readthedocs.io/" Homepage = "https://github.com/crate-py/rpds" Issues = "https://github.com/crate-py/rpds/issues/" Funding = "https://github.com/sponsors/Julian" @@ -40,12 +41,106 @@ Source = "https://github.com/crate-py/rpds" [tool.black] line-length = 79 +[tool.coverage.html] +show_contexts = true +skip_covered = false + +[tool.coverage.run] +branch = true +dynamic_context = "test_function" + +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", + "\\s*\\.\\.\\.\\s*", +] +fail_under = 100 +show_missing = true +skip_covered = true + +[tool.doc8] +ignore = [ + "D000", # see PyCQA/doc8#125 + "D001", # one sentence per line, so max length doesn't make sense +] + [tool.isort] combine_as_imports = true +ensure_newline_before_comments = true from_first = true include_trailing_comma = true multi_line_output = 3 known_first_party = ["rpds"] +use_parentheses = true [tool.maturin] features = ["pyo3/extension-module"] + +[tool.pyright] +reportUnnecessaryTypeIgnoreComment = true +strict = ["**/*"] +exclude = [ + "**/tests/__init__.py", + "**/tests/test_*.py", +] + +[tool.ruff] +line-length = 79 +select = ["ALL"] +ignore = [ + "A001", # It's fine to shadow builtins + "A002", + "A003", + "ARG", # This is all wrong whenever an interface is involved + "ANN", # Just let the type checker do this + "B008", # It's totally OK to call functions for default arguments. + "B904", # raise SomeException(...) is fine. + "B905", # No need for explicit strict, this is simply zip's default behavior + "C408", # Calling dict is fine when it saves quoting the keys + "C901", # Not really something to focus on + "D105", # It's fine to not have docstrings for magic methods. + "D107", # __init__ especially doesn't need a docstring + "D200", # This rule makes diffs uglier when expanding docstrings + "D203", # No blank lines before docstrings. + "D212", # Start docstrings on the second line. + "D400", # This rule misses sassy docstrings ending with ! or ? + "D401", # This rule is too flaky. + "D406", # Section headers should end with a colon not a newline + "D407", # Underlines aren't needed + "D412", # Plz spaces after section headers + "EM101", # These don't bother me, it's fine there's some duplication. + "EM102", + "FBT", # It's worth avoiding boolean args but I don't care to enforce it + "FIX", # Yes thanks, if I could it wouldn't be there + "I001", # We can't yet use ruff's isort + "N", # These naming rules are silly + "PLR0912", # These metrics are fine to be aware of but not to enforce + "PLR0913", + "PLR0915", + "PLW2901", # Shadowing for loop variables is occasionally fine. + "PT006", # pytest parametrize takes strings as well + "PYI025", # wat, I'm not confused, thanks. + "RET502", # Returning None implicitly is fine + "RET503", + "RET505", # These push you to use `if` instead of `elif`, but for no reason + "RET506", + "RSE102", # Ha, what, who even knew you could leave the parens off. But no. + "SIM300", # Not sure what heuristic this uses, but it's easily incorrect + "SLF001", # Private usage within this package itself is fine + "TD", # These TODO style rules are also silly + "UP007", # We support 3.8 + 3.9 +] +[tool.ruff.lint.flake8-pytest-style] +mark-parentheses = false + +[tool.ruff.flake8-quotes] +docstring-quotes = "double" + +[tool.ruff.lint.isort] +combine-as-imports = true +from-first = true + +[tool.ruff.per-file-ignores] +"noxfile.py" = ["ANN", "D100", "S101", "T201"] +"docs/*" = ["ANN", "D", "INP001"] +"tests/*" = ["ANN", "B018", "D", "PLR", "RUF012", "S", "SIM", "TRY"] diff --git a/rpds.pyi b/rpds.pyi index 9f08b65..417ed22 100644 --- a/rpds.pyi +++ b/rpds.pyi @@ -1,5 +1,4 @@ from typing import ( - FrozenSet, ItemsView, Iterable, Iterator, @@ -9,55 +8,57 @@ from typing import ( ValuesView, ) -T = TypeVar("T") -KT = TypeVar("KT", covariant=True) -VT = TypeVar("VT", covariant=True) +_T = TypeVar("_T") +_KT_co = TypeVar("_KT_co", covariant=True) +_VT_co = TypeVar("_VT_co", covariant=True) -class HashTrieMap(Mapping[KT, VT]): +class HashTrieMap(Mapping[_KT_co, _VT_co]): def __init__( self, - value: Mapping[KT, VT] | Iterable[tuple[KT, VT]] = {}, - **kwds: Mapping[KT, VT], + value: Mapping[_KT_co, _VT_co] | Iterable[tuple[_KT_co, _VT_co]] = {}, + **kwds: Mapping[_KT_co, _VT_co], ): ... - def __getitem__(self, key: KT) -> VT: ... - def __iter__(self) -> Iterator[KT]: ... + def __getitem__(self, key: _KT_co) -> _VT_co: ... + def __iter__(self) -> Iterator[_KT_co]: ... def __len__(self) -> int: ... - def discard(self, key: KT) -> "HashTrieMap[KT, VT]": ... - def items(self) -> ItemsView[KT, VT]: ... - def keys(self) -> KeysView[KT]: ... - def values(self) -> ValuesView[VT]: ... - def remove(self, key: KT) -> "HashTrieMap[KT, VT]": ... - def insert(self, key: KT, val: VT) -> "HashTrieMap[KT, VT]": ... + def discard(self, key: _KT_co) -> HashTrieMap[_KT_co, _VT_co]: ... + def items(self) -> ItemsView[_KT_co, _VT_co]: ... + def keys(self) -> KeysView[_KT_co]: ... + def values(self) -> ValuesView[_VT_co]: ... + def remove(self, key: _KT_co) -> HashTrieMap[_KT_co, _VT_co]: ... + def insert( + self, key: _KT_co, val: _VT_co + ) -> HashTrieMap[_KT_co, _VT_co]: ... def update(self, *args: Mapping): ... @classmethod def convert( cls, - value: Mapping[KT, VT] | Iterable[tuple[KT, VT]], - ) -> "HashTrieMap[KT, VT]": ... + value: Mapping[_KT_co, _VT_co] | Iterable[tuple[_KT_co, _VT_co]], + ) -> HashTrieMap[_KT_co, _VT_co]: ... -class HashTrieSet(FrozenSet[T]): - def __init__(self, value: Iterable[T] = ()): ... - def __iter__(self) -> Iterator[T]: ... +class HashTrieSet(frozenset[_T]): + def __init__(self, value: Iterable[_T] = ()): ... + def __iter__(self) -> Iterator[_T]: ... def __len__(self) -> int: ... - def discard(self, value: T) -> "HashTrieSet[T]": ... - def remove(self, value: T) -> "HashTrieSet[T]": ... - def insert(self, value: T) -> "HashTrieSet[T]": ... - def update(self, *args: Iterable[T]) -> "HashTrieSet[T]": ... + def discard(self, value: _T) -> HashTrieSet[_T]: ... + def remove(self, value: _T) -> HashTrieSet[_T]: ... + def insert(self, value: _T) -> HashTrieSet[_T]: ... + def update(self, *args: Iterable[_T]) -> HashTrieSet[_T]: ... -class List(Iterable[T]): - def __init__(self, value: Iterable[T] = (), *more: T): ... - def __iter__(self) -> Iterator[T]: ... +class List(Iterable[_T]): + def __init__(self, value: Iterable[_T] = (), *more: _T): ... + def __iter__(self) -> Iterator[_T]: ... def __len__(self) -> int: ... - def push_front(self, value: T) -> "List[T]": ... - def drop_first(self) -> "List[T]": ... + def push_front(self, value: _T) -> List[_T]: ... + def drop_first(self) -> List[_T]: ... -class Queue(Iterable[T]): - def __init__(self, value: Iterable[T] = (), *more: T): ... - def __iter__(self) -> Iterator[T]: ... +class Queue(Iterable[_T]): + def __init__(self, value: Iterable[_T] = (), *more: _T): ... + def __iter__(self) -> Iterator[_T]: ... def __len__(self) -> int: ... - def enqueue(self, T) -> "Queue[T]": ... - def dequeue(self, T) -> "Queue[T]": ... + def enqueue(self, _T) -> Queue[_T]: ... + def dequeue(self, _T) -> Queue[_T]: ... @property - def is_empty(self) -> T: ... + def is_empty(self) -> _T: ... @property - def peek(self) -> T: ... + def peek(self) -> _T: ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/requirements.txt b/tests/requirements.txt index 8b2acf1..ea16efd 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,14 +1,14 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile tests/requirements.in +# pip-compile --strip-extras tests/requirements.in # iniconfig==2.0.0 # via pytest -packaging==23.1 +packaging==23.2 # via pytest pluggy==1.3.0 # via pytest -pytest==7.4.2 +pytest==7.4.3 # via -r tests/requirements.in diff --git a/tests/test_hash_trie_map.py b/tests/test_hash_trie_map.py index a94d0f6..9a6b672 100644 --- a/tests/test_hash_trie_map.py +++ b/tests/test_hash_trie_map.py @@ -169,7 +169,7 @@ def test_same_hash_when_content_the_same_but_underlying_vector_size_differs(): x = x.remove(z) assert x == y - # assert hash(x) == hash(y) + # assert hash(x) == hash(y) # noqa: ERA001 class HashabilityControlled: @@ -263,7 +263,7 @@ def __eq__(self, other): def test_iteration_with_many_elements(): - values = list(range(0, 2000)) + values = list(range(2000)) keys = [str(x) for x in values] init_dict = dict(zip(keys, values)) @@ -283,8 +283,8 @@ def test_iteration_with_many_elements(): actual_values.add(v) actual_keys.add(k) - assert actual_keys == set(keys + [hash_dummy1, hash_dummy2]) - assert actual_values == set(values + [12345, 54321]) + assert actual_keys == {*keys, hash_dummy1, hash_dummy2} + assert actual_values == {*values, 12345, 54321} def test_repr(): @@ -337,7 +337,7 @@ def test_more_eq(): def test_pickle(): assert pickle.loads( - pickle.dumps(HashTrieMap([(1, 2), (3, 4)])) + pickle.dumps(HashTrieMap([(1, 2), (3, 4)])), ) == HashTrieMap([(1, 2), (3, 4)]) diff --git a/tests/test_hash_trie_set.py b/tests/test_hash_trie_set.py index 8048251..88180c8 100644 --- a/tests/test_hash_trie_set.py +++ b/tests/test_hash_trie_set.py @@ -142,7 +142,7 @@ def test_repr(): def test_update(): assert HashTrieSet([1, 2, 3]).update([3, 4, 4, 5]) == HashTrieSet( - [1, 2, 3, 4, 5] + [1, 2, 3, 4, 5], ) @@ -187,5 +187,5 @@ def test_more_set_comparisons(): def test_pickle(): assert pickle.loads( - pickle.dumps(HashTrieSet([1, 2, 3, 4])) + pickle.dumps(HashTrieSet([1, 2, 3, 4])), ) == HashTrieSet([1, 2, 3, 4])