Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,12 @@ from dol import Files
s = wrap_kvs(Files("/data"), obj_of_data=json.loads, data_of_obj=json.dumps)
```

Run tests: `pytest dol/tests/`
Run tests: a bare `pytest` (from the repo root) runs exactly what CI runs — the
`dol/tests/` unit tests **and** every module doctest, with CI's doctest flags.
Narrow it with `pytest dol/tests/` (unit tests only) or `pytest dol/caching.py`
(one module's doctests). Do not add `NORMALIZE_WHITESPACE` to
`doctest_optionflags`: CI does not pass it, so doctests relying on it would pass
locally and fail in CI.

---

Expand Down
45 changes: 13 additions & 32 deletions dol/caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,24 @@

import os
import types
from typing import Optional, KT, VT, Any, Union, T
from collections.abc import Callable
from collections.abc import Mapping
from functools import RLock, cached_property, partial, wraps
from types import GenericAlias
from typing import Any, Optional, Protocol, TypeVar, Union
from collections.abc import Callable, Mapping, MutableMapping

from dol.base import Store
from dol.trans import store_decorator

from functools import RLock, cached_property
from types import GenericAlias
from collections.abc import MutableMapping
# Type variables
KT = TypeVar("KT") # Key type
VT = TypeVar("VT") # Value type
T = TypeVar("T") # Generic type

#: Sentinel marking "no value cached yet". Distinct from every user value, so a
#: legitimately cached ``None`` is not mistaken for a cache miss.
_NOT_FOUND = object()

# Type definitions
Instance = Any
PropertyFunc = Callable[[Instance], VT]
MethodName = str
Expand All @@ -97,32 +104,6 @@ def identity(x: T) -> T:
return x


from functools import RLock, partial, wraps
from types import GenericAlias
from collections.abc import MutableMapping
from typing import Optional, TypeVar, Union, Any, Protocol
from collections.abc import Callable

# Type variables
KT = TypeVar("KT") # Key type
VT = TypeVar("VT") # Value type
T = TypeVar("T") # Generic type

# Constants
_NOT_FOUND = object()

# Type definitions
Instance = Any
PropertyFunc = Callable[[Instance], VT]
MethodName = str
Cache = Union[MethodName, MutableMapping[KT, VT]]


def identity(x: T) -> T:
"""Identity function that returns its input unchanged."""
return x


class KeyStrategy(Protocol):
"""Protocol defining how a key strategy should behave."""

Expand Down
Empty file added dol/py.typed
Empty file.
32 changes: 32 additions & 0 deletions dol/tests/test_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import pytest
from functools import partial, cached_property
from collections import UserDict
from pathlib import Path
from typing import Dict, Any

from dol import caching as caching_module

# Import the refactored implementations - adjust the import path as needed
from dol.caching import (
cache_this,
Expand Down Expand Up @@ -1117,5 +1120,34 @@ def g(self):
assert cache["g"] == 8, "Value from disk should be in cache"


#: Definitions that must appear exactly once in ``dol/caching.py``. A second,
#: shadowing copy of the module header silently kills the doctests of whatever
#: it shadows (pytest collects the *live* object, not the dead source).
SINGLY_DEFINED_IN_CACHING = (
"def identity(",
"Instance = Any",
"PropertyFunc = Callable[[Instance], VT]",
"MethodName = str",
"Cache = Union[MethodName, MutableMapping[KT, VT]]",
)


@pytest.mark.parametrize("definition", SINGLY_DEFINED_IN_CACHING)
def test_caching_module_header_is_not_duplicated(definition):
"""``dol.caching`` must not define its header twice."""
source = Path(caching_module.__file__).read_text(encoding="utf-8")
count = source.count(definition)
assert count == 1, f"{definition!r} appears {count} times in dol/caching.py"


def test_identity_keeps_its_doctests():
"""The live ``dol.caching.identity`` must be the documented one.

A second, undocumented ``identity`` further down the module used to shadow
the documented one, so its three doctests were never actually run.
"""
assert ">>> identity(42)" in caching_module.identity.__doc__


if __name__ == "__main__":
pytest.main(["-xvs", __file__])
101 changes: 101 additions & 0 deletions dol/tests/test_packaging_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Packaging and test-configuration hygiene checks for the ``dol`` distribution.

``dol`` sits at the bottom of a large dependency tree, so a few "boring"
packaging properties are worth asserting explicitly instead of trusting by
inspection:

- The :pep:`561` ``py.typed`` marker must be present *and shipped*. Without it,
every ``from dol import ...`` in a downstream package is typed as ``Any``.
- The pytest configuration must point at paths that actually exist and enable
the same doctest collection CI uses. Otherwise a green local ``pytest`` says
nothing about a green CI run, on the very package everything depends on.
"""

from pathlib import Path

import pytest

import dol

#: Name of the PEP 561 marker file that tells type checkers the package is typed.
PY_TYPED_MARKER_NAME = "py.typed"

#: Doctest option flags the wads ``run-tests-uv`` CI action passes via ``-o``.
#: Local ``doctest_optionflags`` must agree with these, or local-green does not
#: imply CI-green (notably, CI does *not* pass ``NORMALIZE_WHITESPACE``).
CI_DOCTEST_OPTIONFLAGS = frozenset({"ELLIPSIS", "IGNORE_EXCEPTION_DETAIL"})

#: pytest ``addopts`` entries required for a bare local ``pytest`` to collect
#: what CI collects.
REQUIRED_ADDOPTS = ("--doctest-modules",)

#: Package root directory (works both for editable and installed distributions).
PKG_DIR = Path(dol.__file__).parent


def _load_toml(path: Path) -> dict:
"""Parse a TOML file, skipping the test if no TOML parser is available."""
try:
import tomllib # Python >= 3.11
except ModuleNotFoundError: # pragma: no cover - Python 3.10 without tomli
try:
import tomli as tomllib
except ModuleNotFoundError:
pytest.skip("No TOML parser available (need Python >= 3.11 or tomli)")
return tomllib.loads(path.read_text(encoding="utf-8"))


def _pytest_ini_options() -> dict:
"""Return ``[tool.pytest.ini_options]``, skipping if there's no source tree.

Installed (non-editable) distributions have no ``pyproject.toml`` next to
the package, so the configuration tests are source-tree-only.
"""
pyproject = PKG_DIR.parent / "pyproject.toml"
if not pyproject.is_file():
pytest.skip("Not running from a source tree (no pyproject.toml)")
return _load_toml(pyproject).get("tool", {}).get("pytest", {}).get("ini_options", {})


def test_py_typed_marker_is_present():
"""The PEP 561 marker must exist inside the package directory."""
marker = PKG_DIR / PY_TYPED_MARKER_NAME
assert marker.is_file(), (
f"Missing {PY_TYPED_MARKER_NAME} in {PKG_DIR.name}/: without it every "
"downstream `from dol import ...` is type-checked as Any."
)


def test_testpaths_all_exist():
"""Every configured ``testpaths`` entry must resolve to a real path.

A ``testpaths`` pointing at a nonexistent directory makes pytest silently
fall back to recursive discovery from the cwd, so what a bare ``pytest``
runs bears no relation to what is configured.
"""
ini_options = _pytest_ini_options()
project_root = PKG_DIR.parent
testpaths = ini_options.get("testpaths", [])
missing = [p for p in testpaths if not (project_root / p).exists()]
assert not missing, f"testpaths entries do not exist: {missing}"


def test_addopts_collect_doctests_like_ci():
"""A bare ``pytest`` must collect doctests, as CI does."""
addopts = _pytest_ini_options().get("addopts", "")
missing = [opt for opt in REQUIRED_ADDOPTS if opt not in addopts]
assert not missing, f"pytest addopts is missing {missing} (got {addopts!r})"


def test_doctest_optionflags_match_ci():
"""Local doctest flags must match the flags the CI action passes.

The wads ``run-tests-uv`` action passes
``-o doctest_optionflags='ELLIPSIS IGNORE_EXCEPTION_DETAIL'``, which
*overrides* the ini value. Configuring anything else locally (e.g. adding
``NORMALIZE_WHITESPACE``) means locally-passing doctests can fail in CI.
"""
flags = frozenset(_pytest_ini_options().get("doctest_optionflags", []))
assert flags == CI_DOCTEST_OPTIONFLAGS, (
f"doctest_optionflags {sorted(flags)} != CI's {sorted(CI_DOCTEST_OPTIONFLAGS)}"
)
30 changes: 27 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
# Declared explicitly (rather than inferred) so the distributed contents are a
# stated fact. `artifacts` guarantees the PEP 561 marker ships even if a future
# ignore rule would otherwise exclude it -- without `dol/py.typed` in the wheel,
# every downstream `from dol import ...` is type-checked as `Any`.
packages = ["dol"]
artifacts = ["dol/py.typed"]

[tool.hatch.build.targets.sdist]
artifacts = ["dol/py.typed"]

[project]
name = "dol"
version = "0.3.57"
Expand Down Expand Up @@ -49,8 +60,18 @@ convention = "google"

[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS"]
# The package dir IS the test root: unit tests live in dol/tests and doctests
# live in the modules themselves. (There is no top-level `tests` dir; naming one
# made pytest silently fall back to recursive discovery and skip all doctests.)
testpaths = ["dol"]
# addopts is the single source of truth for what gets collected, so a bare local
# `pytest` collects exactly what CI collects. Hence `exclude_paths = []` below.
addopts = "--doctest-modules --ignore=dol/scrap"
# Must match the flags the wads `run-tests-uv` action passes via `-o` (which
# override this ini value). Notably CI does NOT pass NORMALIZE_WHITESPACE, so
# configuring it here would let whitespace-fragile doctests pass locally and
# fail in CI.
doctest_optionflags = ["ELLIPSIS", "IGNORE_EXCEPTION_DETAIL"]

[tool.wads.ci]
project_name = ""
Expand Down Expand Up @@ -82,7 +103,10 @@ pytest_args = ["-v", "--tb=short"]
coverage_enabled = true
coverage_threshold = 0
coverage_report_format = ["term", "xml"]
exclude_paths = ["examples", "scrap"]
# Empty on purpose: exclusions are declared once, in pytest's `addopts` above,
# so local and CI runs collect the same set. (The old ["examples", "scrap"] was
# resolved against the repo root and therefore matched nothing.)
exclude_paths = []
test_on_windows = true

[tool.wads.ci.build]
Expand Down
27 changes: 0 additions & 27 deletions setup.cfg

This file was deleted.

Loading