Skip to content

Commit

Permalink
Add stubs for wrapped methods to trio.Path, add tests for mypy seeing…
Browse files Browse the repository at this point in the history
… exported symbols, and test for seeing class members
  • Loading branch information
jakkdl committed Apr 12, 2023
1 parent 4f17d2b commit c5b52e4
Show file tree
Hide file tree
Showing 3 changed files with 223 additions and 5 deletions.
68 changes: 66 additions & 2 deletions trio/_path.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# type: ignore

from functools import wraps, partial
import os
import types
import pathlib
from typing import TYPE_CHECKING
import sys

import trio
from trio._util import async_wraps, Final
Expand Down Expand Up @@ -182,6 +182,70 @@ async def open(self, *args, **kwargs):
value = await trio.to_thread.run_sync(func)
return trio.wrap_file(value)

if TYPE_CHECKING:
# the dunders listed in _forward_magic that aren't seen otherwise
__bytes__ = pathlib.Path.__bytes__
__truediv__ = pathlib.Path.__truediv__
__rtruediv__ = pathlib.Path.__rtruediv__

# wrapped methods handled by __getattr__
# TODO: write some decorator/wrapper/help function so these get seen
# as async methods, while inheriting annotations for parameters and return values
# from their corresponding methods in Path
absolute = pathlib.Path.absolute
as_posix = pathlib.Path.as_posix
as_uri = pathlib.Path.as_uri
chmod = pathlib.Path.chmod
cwd = pathlib.Path.cwd
exists = pathlib.Path.exists
expanduser = pathlib.Path.expanduser
glob = pathlib.Path.glob
group = pathlib.Path.group
home = pathlib.Path.home
is_absolute = pathlib.Path.is_absolute
is_block_device = pathlib.Path.is_block_device
is_char_device = pathlib.Path.is_char_device
is_dir = pathlib.Path.is_dir
is_fifo = pathlib.Path.is_fifo
is_file = pathlib.Path.is_file
is_mount = pathlib.Path.is_mount
is_reserved = pathlib.Path.is_reserved
is_socket = pathlib.Path.is_socket
is_symlink = pathlib.Path.is_symlink
iterdir = pathlib.Path.iterdir
joinpath = pathlib.Path.joinpath
lchmod = pathlib.Path.lchmod
lstat = pathlib.Path.lstat
match = pathlib.Path.match
mkdir = pathlib.Path.mkdir
owner = pathlib.Path.owner
read_bytes = pathlib.Path.read_bytes
read_text = pathlib.Path.read_text
relative_to = pathlib.Path.relative_to
rename = pathlib.Path.rename
replace = pathlib.Path.replace
resolve = pathlib.Path.resolve
rglob = pathlib.Path.rglob
rmdir = pathlib.Path.rmdir
samefile = pathlib.Path.samefile
stat = pathlib.Path.stat
symlink_to = pathlib.Path.symlink_to
touch = pathlib.Path.touch
unlink = pathlib.Path.unlink
with_name = pathlib.Path.with_name
with_suffix = pathlib.Path.with_suffix
write_bytes = pathlib.Path.write_bytes
write_text = pathlib.Path.write_text

if sys.version_info >= (3, 8):
link_to = pathlib.Path.link_to
if sys.version_info >= (3, 9):
is_relative_to = pathlib.Path.is_relative_to
with_stem = pathlib.Path.with_stem
readlink = pathlib.Path.readlink
if sys.version_info >= (3, 10):
hardlink_to = pathlib.Path.hardlink_to


Path.iterdir.__doc__ = """
Like :meth:`pathlib.Path.iterdir`, but async.
Expand Down
1 change: 0 additions & 1 deletion trio/_path.pyi

This file was deleted.

159 changes: 157 additions & 2 deletions trio/tests/test_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
from .. import _core
from .. import _util

from typing import Iterable, Any
from types import ModuleType
import socket as stdlib_socket
from .._core.tests.tutil import slow
from trio.tests.conftest import RUN_SLOW


def test_core_is_properly_reexported():
# Each export from _core should be re-exported by exactly one of these
Expand Down Expand Up @@ -65,12 +71,12 @@ def public_modules(module):
reason="skip static introspection tools on Python dev/alpha releases",
)
@pytest.mark.parametrize("modname", PUBLIC_MODULE_NAMES)
@pytest.mark.parametrize("tool", ["pylint", "jedi"])
@pytest.mark.parametrize("tool", ["pylint", "jedi", "mypy"])
@pytest.mark.filterwarnings(
# https://github.com/pypa/setuptools/issues/3274
"ignore:module 'sre_constants' is deprecated:DeprecationWarning",
)
def test_static_tool_sees_all_symbols(tool, modname):
def test_static_tool_sees_all_symbols(tool, modname, tmpdir):
module = importlib.import_module(modname)

def no_underscores(symbols):
Expand All @@ -96,6 +102,24 @@ def no_underscores(symbols):
script = jedi.Script(f"import {modname}; {modname}.")
completions = script.complete()
static_names = no_underscores(c.name for c in completions)
elif tool == "mypy":
if not RUN_SLOW:
pytest.skip("use --run-slow to check against mypy")
# mypy behaves strangely when passed a huge semicolon-separated line with `-c`
# so we use a tmpfile
tmpfile = tmpdir / "check_mypy.py"
tmpfile.write_text(
f"import {modname}\n"
+ "".join(f"{modname}.{name}\n" for name in runtime_names),
encoding="utf8",
)
from mypy.api import run

res = run(["--config-file=", "--follow-imports=silent", str(tmpfile)])

# check that there were no errors (exit code 0), otherwise print the errors
assert res[2] == 0, res[0]
return
else: # pragma: no cover
assert False

Expand All @@ -114,6 +138,137 @@ def no_underscores(symbols):
assert False


# this could be sped up by only invoking mypy once per module, or even once for all
# modules, instead of once per class.
@slow
# see commend on test_static_tool_sees_all_symbols
@pytest.mark.redistributors_should_skip
# pylint/jedi often have trouble with alpha releases, where Python's internals
# are in flux, grammar may not have settled down, etc.
@pytest.mark.skipif(
sys.version_info.releaselevel == "alpha",
reason="skip static introspection tools on Python dev/alpha releases",
)
@pytest.mark.parametrize("module_name", PUBLIC_MODULE_NAMES)
@pytest.mark.parametrize("tool", ["jedi", "mypy"])
def test_static_tool_sees_class_members(tool, module_name, tmpdir) -> None:
module = PUBLIC_MODULES[PUBLIC_MODULE_NAMES.index(module_name)]

# ignore hidden, but not dunder, symbols
def no_hidden(symbols):
return {
symbol
for symbol in symbols
if (not symbol.startswith("_")) or symbol.startswith("__")
}

errors: dict[str, Any] = {}
if module_name == "trio.tests":
return
for class_name, class_ in module.__dict__.items():
if not isinstance(class_, type):
continue
if module_name == "trio.socket" and class_name in dir(stdlib_socket):
continue
# Deprecated classes are exported with a leading underscore
if class_name.startswith("_"): # pragma: no cover
continue

# dir() and inspect.getmembers doesn't display properties from the metaclass
# also ignore some dunder methods that tend to differ but are of no consequence
ignore_names = set(dir(type(class_))) | {
"__annotations__",
"__attrs_attrs__",
"__attrs_own_setattr__",
"__class_getitem__",
"__getstate__",
"__match_args__",
"__order__",
"__orig_bases__",
"__parameters__",
"__setstate__",
"__slots__",
"__weakref__",
}

# inspect.getmembers sees `name` and `value` in Enums, otherwise
# it behaves the same way as `dir`
# runtime_names = no_underscores(dir(class_))
runtime_names = (
no_hidden(x[0] for x in inspect.getmembers(class_)) - ignore_names
)

if tool == "jedi":
import jedi

script = jedi.Script(
f"from {module_name} import {class_name}; {class_name}."
)
completions = script.complete()
static_names = no_hidden(c.name for c in completions) - ignore_names

missing = runtime_names - static_names
extra = static_names - runtime_names
if BaseException in class_.__mro__ and sys.version_info > (3, 11):
missing.remove("add_note")

# TODO: why is this? Is it a problem?
if class_ == trio.StapledStream:
extra.remove("receive_stream")
extra.remove("send_stream")

if missing | extra:
errors[f"{module_name}.{class_name}"] = {
"missing": missing,
"extra": extra,
}
elif tool == "mypy":
tmpfile = tmpdir / "check_mypy.py"
sorted_runtime_names = list(sorted(runtime_names))
content = f"from {module_name} import {class_name}\n" + "".join(
f"{class_name}.{name}\n" for name in sorted_runtime_names
)
tmpfile.write_text(content, encoding="utf8")
from mypy.api import run

res = run(
[
"--config-file=",
"--follow-imports=silent",
"--disable-error-code=operator",
str(tmpfile),
]
)
if res[2] != 0:
it = iter(res[0].split("\n")[:-2])
for output_line in it:
kk = output_line.split(":")

# -2 due to lines being 1-indexed and to skip the import line
symbol = (
f"{module_name}.{class_name}."
+ sorted_runtime_names[int(kk[1]) - 2]
)
error = kk[2] + ":" + kk[3]

# a bunch of symbols have this error, e.g. trio.lowlevel.Task.context
# but I don't think that's a problem - and if it is it would likely
# be picked up by "proper" mypy tests elsewhere
if "conflicts with class variable access" in error:
continue

errors[symbol] = error
else: # pragma: no cover
assert False

if errors: # pragma: no cover
from pprint import pprint

print(f"\n{tool} can't see the following symbols in {module_name}:")
pprint(errors)
assert not errors


def test_classes_are_final():
for module in PUBLIC_MODULES:
for name, class_ in module.__dict__.items():
Expand Down

0 comments on commit c5b52e4

Please sign in to comment.