Skip to content

Commit

Permalink
--no-edit
Browse files Browse the repository at this point in the history
  • Loading branch information
jakkdl committed Apr 14, 2023
1 parent 4f17d2b commit f3f43d5
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 13 deletions.
89 changes: 84 additions & 5 deletions trio/_path.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
# type: ignore

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

import trio
from trio._util import async_wraps, Final
from trio._util import Final, async_wraps

if sys.version_info < (3, 10):
from typing_extensions import ParamSpec
else:
from typing import ParamSpec

if TYPE_CHECKING:
ArgsT = ParamSpec("ArgsT")
RetT = TypeVar("RetT")

def _make_async(func: Callable[ArgsT, RetT]) -> Callable[ArgsT, Awaitable[RetT]]:
...


# re-wrap return value from methods that return new instances of pathlib.Path
Expand Down Expand Up @@ -182,6 +194,73 @@ 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.

# 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)
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_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)
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.platform != "win32":
group = _make_async(pathlib.Path.group)
is_mount = _make_async(pathlib.Path.is_mount)
owner = _make_async(pathlib.Path.owner)

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.

182 changes: 175 additions & 7 deletions trio/tests/test_exports.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import enum
import importlib
import inspect
import re
import socket as stdlib_socket
import sys
import importlib
import types
import inspect
import enum
from pathlib import Path
from types import ModuleType
from typing import Any, Iterable

import pytest

import trio
import trio.testing
from trio.tests.conftest import RUN_SLOW

from .. import _core
from .. import _util
from .. import _core, _util
from .._core.tests.tutil import slow


def test_core_is_properly_reexported():
Expand Down Expand Up @@ -65,12 +70,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 +101,30 @@ 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")
if sys.implementation_name != "cpython":
pytest.skip("mypy not installed in tests on pypy")

# create py.typed file
(Path(trio.__file__).parent / "py.typed").write_text("")

# 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 +143,145 @@ 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("__")
}

if tool == "mypy":
if sys.implementation_name != "cpython":
pytest.skip("mypy not installed in tests on pypy")
# create py.typed file
(Path(trio.__file__).parent / "py.typed").write_text("")

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:
print(res)
it = iter(res[0].split("\n")[:-2])
for output_line in it:
print(output_line)
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 f3f43d5

Please sign in to comment.