Skip to content
Open
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
58 changes: 54 additions & 4 deletions autoregistry/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@
from inspect import ismodule
from pathlib import Path
from types import FunctionType, MethodType
from typing import Any, Callable, Generator, Iterable, Type, Union
from typing import (
Any,
Callable,
Generator,
Iterable,
Optional,
Tuple,
Type,
Union,
)

from .config import RegistryConfig
from .exceptions import (
Expand Down Expand Up @@ -250,10 +259,14 @@ def keys(self) -> KeysView:
def values(self) -> ValuesView:
return self.__registry__.values()

def items(self):
def items(self) -> Generator[Tuple[str, Type], None, None]:
yield from self.__registry__.items()

def get(self, key: Union[str, Type], default=None) -> Type:
def get(
self,
key: str,
default: Union[str, Type, None] = None,
) -> Optional[Type]:
try:
return self[key]
except KeyError:
Expand Down Expand Up @@ -387,6 +400,12 @@ def __repr__(cls):


class Registry(metaclass=RegistryMeta, base=True):
# These class-level ``Callable`` annotations exist so that static checkers
# see that ``Registry`` instances (created via the decorator pattern
# ``my_reg = Registry()`` which returns a ``RegistryDecorator``) support
# the dict-like interface. The class-level (metaclass) side is handled by
# ``RegistryMeta`` inheriting from ``_DictMixin``; these annotations cover
# the instance side.
__call__: Callable
__contains__: Callable[..., bool]
__getitem__: Callable[[str], Type]
Expand All @@ -399,10 +418,41 @@ class Registry(metaclass=RegistryMeta, base=True):
keys: Callable[[], KeysView]
values: Callable[[], ValuesView]

# The real work happens in ``RegistryMeta.__new__`` via ``**config``.
# This explicit ``__init_subclass__`` signature exists so that static type
# checkers (pyright, mypy) recognize class-definition kwargs like
# ``class Foo(Registry, snake_case=True)``. Keep the ``RegistryConfig``
# fields below in sync with ``autoregistry/config.py``.
def __init_subclass__(
cls,
*,
# ``RegistryMeta.__new__`` explicit parameters
name: Optional[str] = None,
aliases: Union[str, Iterable[str], None] = None,
skip: bool = False,
base: bool = False,
# ``RegistryConfig`` fields
case_sensitive: Optional[bool] = None,
prefix: Optional[str] = None,
suffix: Optional[str] = None,
strip_prefix: Optional[bool] = None,
strip_suffix: Optional[bool] = None,
regex: Optional[str] = None,
register_self: Optional[bool] = None,
recursive: Optional[bool] = None,
snake_case: Optional[bool] = None,
hyphen: Optional[bool] = None,
transform: Optional[Callable[[str], str]] = None,
overwrite: Optional[bool] = None,
redirect: Optional[bool] = None,
**kwargs: Any,
) -> None:
super().__init_subclass__(**kwargs)

def __new__(cls, *args, **kwargs):
if cls is Registry:
# A Registry is being explicitly created for decorating
return super().__new__(RegistryDecorator)
return super().__new__(RegistryDecorator) # type: ignore[misc]
else:
# Registry is being subclassed
return super().__new__(cls)
Expand Down
4 changes: 2 additions & 2 deletions autoregistry/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import dataclasses
import re
from dataclasses import asdict, dataclass
from typing import Callable, Optional
from typing import Any, Callable, Optional

from .exceptions import InvalidNameError
from .regex import hyphenate, key_split, to_snake_case
Expand Down Expand Up @@ -60,7 +60,7 @@ def update(self, new: dict) -> None:
else:
raise TypeError(f"Unexpected configuration value {key}={value}")

def getitem(self, registry: dict, key: str):
def getitem(self, registry: dict, key: str) -> Any:
"""Key/Value lookup with keysplitting and optional case-insensitivity."""
keys = key_split(key)
for key in keys:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ select = [

ignore = [
"B905", # zip strict=True; remove once python <3.10 support is dropped.
"S603", # subprocess call: check for execution of untrusted input
"D100",
"D101",
"D102",
Expand Down
2 changes: 1 addition & 1 deletion tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

def construct_pokemon_classes(**kwargs):
@dataclass
class Pokemon(Registry, **kwargs): # type: ignore[reportGeneralTypeIssues]
class Pokemon(Registry, **kwargs):
level: int
hp: int

Expand Down
165 changes: 165 additions & 0 deletions tests/test_typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Pyright-based regression tests for autoregistry's public typing contract.

These tests run pyright as a subprocess against small snippets that exercise
the typing guarantees autoregistry tries to maintain. They guard against
regressions in the type-checker-visible surface (e.g., Issue 1: unknown
``__init_subclass__`` kwargs should NOT error; unknown kwargs SHOULD error).

The tests are skipped if pyright is not installed on PATH.
"""

import json
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path

import pytest

pyright = shutil.which("pyright")
pytestmark = pytest.mark.skipif(
pyright is None,
reason="pyright not installed",
)


def _run_pyright(snippet: str, tmp_path: Path) -> dict:
"""Write ``snippet`` to a temporary file and return pyright's JSON report."""
file = tmp_path / "snippet.py"
file.write_text(textwrap.dedent(snippet))
assert pyright is not None
proc = subprocess.run(
[pyright, "--outputjson", str(file)],
capture_output=True,
text=True,
check=False,
)
# pyright exits non-zero when errors are found; that's expected for
# negative tests. The JSON is on stdout regardless.
return json.loads(proc.stdout)


def _errors_on_line(report: dict, line_1_indexed: int) -> list:
"""Return the list of error diagnostics on a given 1-indexed source line."""
return [
d
for d in report.get("generalDiagnostics", [])
if d.get("severity") == "error"
and d["range"]["start"]["line"] == line_1_indexed - 1
]


def test_init_subclass_accepts_known_kwargs(tmp_path: Path) -> None:
"""Known config kwargs on a Registry subclass must not produce errors.

Regression for Issue 1 in ``autoregistry-issue.md``.
"""
snippet = """
from autoregistry import Registry

class Pokemon(
Registry,
snake_case=True,
prefix="Poke",
suffix="",
recursive=False,
case_sensitive=False,
register_self=False,
overwrite=False,
redirect=True,
strip_prefix=True,
strip_suffix=True,
regex="",
hyphen=False,
base=False,
):
pass
"""
report = _run_pyright(snippet, tmp_path)
errors = [
d for d in report.get("generalDiagnostics", []) if d.get("severity") == "error"
]
assert errors == [], f"Unexpected errors: {errors}"


def test_init_subclass_signature_declares_config_kwargs(tmp_path: Path) -> None:
"""``Registry.__init_subclass__`` must advertise the config kwargs.

Verifies via ``reveal_type`` that the signature lists the known
``RegistryConfig`` parameters. This guards against regressions where
someone removes ``__init_subclass__`` or forgets to add a new field.
"""
snippet = """
from autoregistry import Registry

reveal_type(Registry.__init_subclass__)
"""
report = _run_pyright(snippet, tmp_path)
info_diags = [
d
for d in report.get("generalDiagnostics", [])
if d.get("severity") == "information"
]
revealed = " ".join(d["message"] for d in info_diags)
assert "snake_case" in revealed, f"snake_case missing from signature: {revealed}"
assert "prefix" in revealed, f"prefix missing from signature: {revealed}"
assert "recursive" in revealed, f"recursive missing from signature: {revealed}"
assert "base" in revealed, f"base missing from signature: {revealed}"


def test_base_kwarg_accepted(tmp_path: Path) -> None:
"""The ``base=True`` kwarg must not be flagged.

Before Issue 1 was fixed, ``autoregistry/pydantic.py`` had a
``# type: ignore[call-arg]`` on ``base=True``. Confirm that's no longer
needed.
"""
snippet = """
from autoregistry import Registry

class BaseReg(Registry, base=True):
pass
"""
report = _run_pyright(snippet, tmp_path)
errors = [
d for d in report.get("generalDiagnostics", []) if d.get("severity") == "error"
]
assert errors == [], f"Unexpected errors for base=True: {errors}"


def test_items_return_type_is_tuple_of_str_and_type(tmp_path: Path) -> None:
"""``.items()`` must reveal ``Generator[tuple[str, Type], ...]``.

Regression for Issue 3: the bare ``items: Callable`` annotation left
iteration types as Unknown. After the fix, ``items()`` has a concrete
return type.

Note: this asserts the class-level instance-method annotation (from
``_DictMixin``), which is what downstream users get when they iterate
via ``MyReg.items()``.
"""
snippet = """
from autoregistry import Registry
from autoregistry._registry import _DictMixin

reveal_type(_DictMixin.items)
"""
report = _run_pyright(snippet, tmp_path)
info_diags = [
d
for d in report.get("generalDiagnostics", [])
if d.get("severity") == "information"
]
revealed = [d["message"] for d in info_diags if "Type of" in d["message"]]
assert revealed, f"reveal_type produced no output. Report: {report}"
joined = " ".join(revealed)
# The reveal should mention the typed Generator return, with a
# str-keyed tuple (pyright renders it as ``Tuple[str, Type[...]]``).
assert (
"Generator" in joined and "Tuple[str" in joined
), f"Expected items() to reveal Generator[Tuple[str, Type], ...]; got: {revealed}"


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
Loading