Skip to content

Commit

Permalink
--no-edit
Browse files Browse the repository at this point in the history
  • Loading branch information
jakkdl committed Apr 13, 2023
1 parent 4f17d2b commit 8b6c935
Show file tree
Hide file tree
Showing 3 changed files with 232 additions and 5 deletions.
77 changes: 75 additions & 2 deletions trio/_path.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# type: ignore

from functools import wraps, partial
import os
import types
import pathlib
from typing import TYPE_CHECKING
from typing import TypeVar, ParamSpec, Awaitable, Callable
import sys

import trio
from trio._util import async_wraps, Final
Expand Down Expand Up @@ -182,6 +183,78 @@ 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__

# Note: these are not parsed properly by jedi.
# It might be superior to just manually implement all the methods and get rid
# of all the magic wrapping stuff.
ArgsT = ParamSpec("ArgsT")
RetT = TypeVar("RetT")

# mypy complains about missing `self` argument
def make_async(func: Callable[ArgsT, RetT] # type: ignore[misc]
) -> Callable[ArgsT, Awaitable[RetT]]:
...

# wrapped methods handled by __getattr__
absolute = make_async(pathlib.Path.absolute)
as_posix = make_async(pathlib.Path.as_posix)
as_uri = make_async(pathlib.Path.as_uri)
chmod = make_async(pathlib.Path.chmod)
cwd = make_async(pathlib.Path.cwd)
exists = make_async(pathlib.Path.exists)
expanduser = make_async(pathlib.Path.expanduser)
glob = make_async(pathlib.Path.glob)
group = make_async(pathlib.Path.group)
home = make_async(pathlib.Path.home)
is_absolute = make_async(pathlib.Path.is_absolute)
is_block_device = make_async(pathlib.Path.is_block_device)
is_char_device = make_async(pathlib.Path.is_char_device)
is_dir = make_async(pathlib.Path.is_dir)
is_fifo = make_async(pathlib.Path.is_fifo)
is_file = make_async(pathlib.Path.is_file)
is_mount = make_async(pathlib.Path.is_mount)
is_reserved = make_async(pathlib.Path.is_reserved)
is_socket = make_async(pathlib.Path.is_socket)
is_symlink = make_async(pathlib.Path.is_symlink)
iterdir = make_async(pathlib.Path.iterdir)
joinpath = make_async(pathlib.Path.joinpath)
lchmod = make_async(pathlib.Path.lchmod)
lstat = make_async(pathlib.Path.lstat)
match = make_async(pathlib.Path.match)
mkdir = make_async(pathlib.Path.mkdir)
owner = make_async(pathlib.Path.owner)
read_bytes = make_async(pathlib.Path.read_bytes)
read_text = make_async(pathlib.Path.read_text)
relative_to = make_async(pathlib.Path.relative_to)
rename = make_async(pathlib.Path.rename)
replace = make_async(pathlib.Path.replace)
resolve = make_async(pathlib.Path.resolve)
rglob = make_async(pathlib.Path.rglob)
rmdir = make_async(pathlib.Path.rmdir)
samefile = make_async(pathlib.Path.samefile)
stat = make_async(pathlib.Path.stat)
symlink_to = make_async(pathlib.Path.symlink_to)
touch = make_async(pathlib.Path.touch)
unlink = make_async(pathlib.Path.unlink)
with_name = make_async(pathlib.Path.with_name)
with_suffix = make_async(pathlib.Path.with_suffix)
write_bytes = make_async(pathlib.Path.write_bytes)
write_text = make_async(pathlib.Path.write_text)

if sys.version_info >= (3, 8):
link_to = make_async(pathlib.Path.link_to)
if sys.version_info >= (3, 9):
is_relative_to = make_async(pathlib.Path.is_relative_to)
with_stem = make_async(pathlib.Path.with_stem)
readlink = make_async(pathlib.Path.readlink)
if sys.version_info >= (3, 10):
hardlink_to = make_async(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 8b6c935

Please sign in to comment.