Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FullyQualifiedNameProvider #465

Merged
merged 1 commit into from
Mar 26, 2021
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
8 changes: 8 additions & 0 deletions docs/source/metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,18 @@ We don't call it `fully qualified name <https://en.wikipedia.org/wiki/Fully_qual
because the name refers to the current module which doesn't consider the hierarchy of
code repository.

For fully qualified names, there's :class:`~libcst.metadata.FullyQualifiedNameProvider`
which is similar to the above but takes the current module's location (relative to some
python root folder, usually the repository's root) into account.


.. autoclass:: libcst.metadata.QualifiedNameSource
.. autoclass:: libcst.metadata.QualifiedName
.. autoclass:: libcst.metadata.QualifiedNameProvider
:no-undoc-members:

.. autoclass:: libcst.metadata.FullyQualifiedNameProvider
:no-undoc-members:

Parent Node Metadata
--------------------
Expand Down
2 changes: 1 addition & 1 deletion libcst/codemod/_codemod.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def _handle_metadata_reference(
oldwrapper = self.context.wrapper
metadata_manager = self.context.metadata_manager
filename = self.context.filename
if metadata_manager and filename:
if metadata_manager is not None and filename:
# We can look up full-repo metadata for this codemod!
cache = metadata_manager.get_cache_for_path(filename)
wrapper = MetadataWrapper(module, cache=cache)
Expand Down
6 changes: 5 additions & 1 deletion libcst/metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
ExpressionContextProvider,
)
from libcst.metadata.full_repo_manager import FullRepoManager
from libcst.metadata.name_provider import QualifiedNameProvider
from libcst.metadata.name_provider import (
FullyQualifiedNameProvider,
QualifiedNameProvider,
)
from libcst.metadata.parent_node_provider import ParentNodeProvider
from libcst.metadata.position_provider import (
PositionProvider,
Expand Down Expand Up @@ -74,6 +77,7 @@
"BatchableMetadataProvider",
"VisitorMetadataProvider",
"QualifiedNameProvider",
"FullyQualifiedNameProvider",
"ProviderT",
"Assignments",
"Accesses",
Expand Down
5 changes: 3 additions & 2 deletions libcst/metadata/full_repo_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ def __init__(
metadata provider like :class:`~libcst.metadata.TypeInferenceProvider`.

:param paths: a collection of paths to access full repository data.
:param providers: a collection of metadata provider classes require accessing full repository
data, currently supports :class:`~libcst.metadata.TypeInferenceProvider`.
:param providers: a collection of metadata provider classes require accessing full repository data, currently supports
:class:`~libcst.metadata.TypeInferenceProvider` and
:class:`~libcst.metadata.FullyQualifiedNameProvider`.
:param timeout: number of seconds. Raises `TimeoutExpired <https://docs.python.org/3/library/subprocess.html#subprocess.TimeoutExpired>`_
when timeout.
"""
Expand Down
105 changes: 103 additions & 2 deletions libcst/metadata/name_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from typing import Collection, Optional, Union
import dataclasses
import re
from pathlib import Path
from typing import Collection, List, Mapping, Optional, Pattern, Union

import libcst as cst
from libcst._metadata_dependent import MetadataDependent
from libcst.metadata.base_provider import BatchableMetadataProvider
from libcst.metadata.scope_provider import QualifiedName, ScopeProvider
from libcst.metadata.scope_provider import (
QualifiedName,
QualifiedNameSource,
ScopeProvider,
)


class QualifiedNameProvider(BatchableMetadataProvider[Collection[QualifiedName]]):
Expand Down Expand Up @@ -76,3 +83,97 @@ def on_visit(self, node: cst.CSTNode) -> bool:
self.provider.set_metadata(node, set())
super().on_visit(node)
return True


DOT_PY: Pattern[str] = re.compile(r"(__init__)?\.py$")


def _module_name(path: str) -> Optional[str]:
return DOT_PY.sub("", path).replace("/", ".").rstrip(".")


class FullyQualifiedNameProvider(BatchableMetadataProvider[Collection[QualifiedName]]):
"""
Provide fully qualified names for CST nodes. Like :class:`QualifiedNameProvider`,
but the provided :class:`QualifiedName`s have absolute identifier names instead of
local to the current module.

This provider is initialized with the current module's fully qualified name, and can
be used with :class:`~libcst.metadata.FullRepoManager`. The module's fully qualified
name itself is stored as a metadata of the :class:`~libcst.Module` node. Compared to
:class:`QualifiedNameProvider`, it also resolves relative imports.

Example usage::

>>> mgr = FullRepoManager(".", {"dir/a.py"}, {FullyQualifiedNameProvider})
>>> wrapper = mgr.get_metadata_wrapper_for_path("dir/a.py")
>>> fqnames = wrapper.resolve(FullyQualifiedNameProvider)
>>> {type(k): v for (k, v) in fqnames.items()}
{<class 'libcst._nodes.module.Module'>: {QualifiedName(name='dir.a', source=<QualifiedNameSource.LOCAL: 3>)}}

"""

METADATA_DEPENDENCIES = (QualifiedNameProvider,)

@classmethod
def gen_cache(
cls, root_path: Path, paths: List[str], timeout: Optional[int] = None
) -> Mapping[str, object]:
cache = {path: _module_name(path) for path in paths}
return cache

def __init__(self, cache: str) -> None:
super().__init__(cache)
self.module_name: str = cache

def visit_Module(self, node: cst.Module) -> bool:
visitor = FullyQualifiedNameVisitor(self, self.module_name)
node.visit(visitor)
self.set_metadata(
node,
{QualifiedName(name=self.module_name, source=QualifiedNameSource.LOCAL)},
)
return True


class FullyQualifiedNameVisitor(cst.CSTVisitor):
@staticmethod
def _fully_qualify_local(module_name: str, qname: QualifiedName) -> str:
name = qname.name
if not name.startswith("."):
# not a relative import
return f"{module_name}.{name}"

# relative import
name = name.lstrip(".")
parts_to_strip = len(qname.name) - len(name)
target_module = ".".join(module_name.split(".")[: -1 * parts_to_strip])
return f"{target_module}.{name}"

@staticmethod
def _fully_qualify(module_name: str, qname: QualifiedName) -> QualifiedName:
if qname.source == QualifiedNameSource.BUILTIN:
# builtins are already fully qualified
return qname
name = qname.name
if qname.source == QualifiedNameSource.IMPORT and not name.startswith("."):
# non-relative imports are already fully qualified
return qname
new_name = FullyQualifiedNameVisitor._fully_qualify_local(module_name, qname)
return dataclasses.replace(qname, name=new_name)

def __init__(self, provider: FullyQualifiedNameProvider, module_name: str) -> None:
self.module_name = module_name
self.provider = provider

def on_visit(self, node: cst.CSTNode) -> bool:
qnames = self.provider.get_metadata(QualifiedNameProvider, node)
if qnames is not None:
self.provider.set_metadata(
node,
{
FullyQualifiedNameVisitor._fully_qualify(self.module_name, qname)
for qname in qnames
},
)
return True
27 changes: 20 additions & 7 deletions libcst/metadata/scope_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,25 @@ def get_name_for(node: Union[str, cst.CSTNode]) -> Optional[str]:
return None

@staticmethod
def find_qualified_name_for_import_alike(
assignment_node: Union[cst.Import, cst.ImportFrom], full_name: str
) -> Set[QualifiedName]:
def get_module_name_for_import_alike(
assignment_node: Union[cst.Import, cst.ImportFrom]
) -> str:
module = ""
results = set()
if isinstance(assignment_node, cst.ImportFrom):
module_attr = assignment_node.module
relative = assignment_node.relative
if module_attr:
# TODO: for relative import, keep the relative Dot in the qualified name
module = get_full_name_for_node(module_attr)
module = get_full_name_for_node(module_attr) or ""
if relative:
module = "." * len(relative) + module
return module

@staticmethod
def find_qualified_name_for_import_alike(
assignment_node: Union[cst.Import, cst.ImportFrom], full_name: str
) -> Set[QualifiedName]:
module = _NameUtil.get_module_name_for_import_alike(assignment_node)
results = set()
import_names = assignment_node.names
if not isinstance(import_names, cst.ImportStar):
for name in import_names:
Expand All @@ -308,7 +317,11 @@ def find_qualified_name_for_import_alike(
real_names = [".".join(parts[:i]) for i in range(len(parts), 0, -1)]
for real_name in real_names:
as_name = real_name
if module:
if module and module.endswith("."):
# from . import a
# real_name should be ".a"
real_name = f"{module}{real_name}"
elif module:
real_name = f"{module}.{real_name}"
if name and name.asname:
eval_alias = name.evaluated_alias
Expand Down
121 changes: 119 additions & 2 deletions libcst/metadata/tests/test_name_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from pathlib import Path
from tempfile import TemporaryDirectory
from textwrap import dedent
from typing import Collection, Mapping, Optional, Set, Tuple

import libcst as cst
from libcst import ensure_type
from libcst.metadata import (
FullyQualifiedNameProvider,
MetadataWrapper,
QualifiedName,
QualifiedNameProvider,
QualifiedNameSource,
)
from libcst.metadata.full_repo_manager import FullRepoManager
from libcst.metadata.name_provider import FullyQualifiedNameVisitor
from libcst.testing.utils import UnitTest


Expand All @@ -25,8 +30,29 @@ def get_qualified_name_metadata_provider(


def get_qualified_names(module_str: str) -> Set[QualifiedName]:
_, qnames = get_qualified_name_metadata_provider(module_str)
return set().union(*qnames.values())
_, qnames_map = get_qualified_name_metadata_provider(module_str)
return {qname for qnames in qnames_map.values() for qname in qnames}


def get_fully_qualified_names(file_path: str, module_str: str) -> Set[QualifiedName]:
wrapper = cst.MetadataWrapper(
cst.parse_module(dedent(module_str)),
# pyre-fixme[6]: Incompatible parameter type [6]: Expected
# `typing.Mapping[typing.Type[cst.metadata.base_provider.BaseMetadataProvider[
# object]], object]` for 2nd parameter `cache` to call
# `cst.metadata.wrapper.MetadataWrapper.__init__` but got
# `typing.Dict[typing.Type[FullyQualifiedNameProvider], object]`
cache={
FullyQualifiedNameProvider: FullyQualifiedNameProvider.gen_cache(
Path(""), [file_path], None
).get(file_path, "")
},
)
return {
qname
for qnames in wrapper.resolve(FullyQualifiedNameProvider).values()
for qname in qnames
}


class QualifiedNameProviderTest(UnitTest):
Expand Down Expand Up @@ -325,3 +351,94 @@ class Foo:
self.assertEqual(
names[attribute], {QualifiedName("a.aa.aaa", QualifiedNameSource.IMPORT)}
)


class FullyQualifiedNameProviderTest(UnitTest):
def test_builtins(self) -> None:
qnames = get_fully_qualified_names(
"test/module.py",
"""
int(None)
""",
)
module_name = QualifiedName(
name="test.module", source=QualifiedNameSource.LOCAL
)
self.assertIn(module_name, qnames)
qnames -= {module_name}
self.assertEqual(
{"builtins.int", "builtins.None"},
{qname.name for qname in qnames},
)
for qname in qnames:
self.assertEqual(qname.source, QualifiedNameSource.BUILTIN, msg=f"{qname}")

def test_imports(self) -> None:
qnames = get_fully_qualified_names(
"some/test/module.py",
"""
from a.b import c as d
from . import rel
from .lol import rel2
from .. import thing as rel3
d, rel, rel2, rel3
""",
)
module_name = QualifiedName(
name="some.test.module", source=QualifiedNameSource.LOCAL
)
self.assertIn(module_name, qnames)
qnames -= {module_name}
self.assertEqual(
{"a.b.c", "some.test.rel", "some.test.lol.rel2", "some.thing"},
{qname.name for qname in qnames},
)
for qname in qnames:
self.assertEqual(qname.source, QualifiedNameSource.IMPORT, msg=f"{qname}")

def test_locals(self) -> None:
qnames = get_fully_qualified_names(
"some/test/module.py",
"""
class X:
a: X
""",
)
self.assertEqual(
{"some.test.module", "some.test.module.X", "some.test.module.X.a"},
{qname.name for qname in qnames},
)
for qname in qnames:
self.assertEqual(qname.source, QualifiedNameSource.LOCAL, msg=f"{qname}")

def test_local_qualification(self) -> None:
base_module = "some.test.module"
for (name, expected) in [
(".foo", "some.test.foo"),
("..bar", "some.bar"),
("foo", "some.test.module.foo"),
]:
with self.subTest(name=name):
self.assertEqual(
FullyQualifiedNameVisitor._fully_qualify_local(
base_module,
QualifiedName(name=name, source=QualifiedNameSource.LOCAL),
),
expected,
)


class FullyQualifiedNameIntegrationTest(UnitTest):
def test_with_full_repo_manager(self) -> None:
with TemporaryDirectory() as dir:
fname = "pkg/mod.py"
(Path(dir) / "pkg").mkdir()
(Path(dir) / fname).touch()
mgr = FullRepoManager(dir, [fname], [FullyQualifiedNameProvider])
wrapper = mgr.get_metadata_wrapper_for_path(fname)
fqnames = wrapper.resolve(FullyQualifiedNameProvider)
(mod, names) = next(iter(fqnames.items()))
self.assertIsInstance(mod, cst.Module)
self.assertEqual(
names, {QualifiedName(name="pkg.mod", source=QualifiedNameSource.LOCAL)}
)