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

Autoimport autodetection #516

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# **Upcoming release**

- ...
- #516 Autoimport Now automatically detects project dependencies and can read TOML configuration

# Release 1.12.0

Expand Down
9 changes: 8 additions & 1 deletion docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Will be used if [tool.rope] is configured.

[tool.rope]
split_imports = true
[tool.rope.autoimport]
underlined = false
Copy link
Member

@lieryan lieryan Jan 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd much prefer to keep a flat namespace here for the config keys. The vast majority of users are going to only have a small amount of rope config, creating multiple config sections just for a handful of configuration seems overkill and creates opportunities for errors. Most users wouldn't care/know about rope internals enough to intuitively know that autoimport is a separate package inside rope.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, toml allows dotted keys. Maybe we can have our cake and eat it too, by recommending the dotted form in the examples section instead of the separate section:

[tool.rope]
autoimport.underlined = true


config.py
---------
Expand Down Expand Up @@ -56,9 +58,14 @@ It follows the exact same syntax of the pyproject.toml.


Options
-------
=======
.. autopytoolconfigtable:: rope.base.prefs.Prefs

Autoimport Options
------------------
.. autopytoolconfigtable:: rope.base.prefs.AutoimportPrefs


Old Configuration File
----------------------
This is a sample config.py. While this config.py works and all options here should be supported, the above documentation reflects the latest version of rope.
Expand Down
10 changes: 10 additions & 0 deletions rope/base/prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
from rope.base.resources import Folder


@dataclass
class AutoimportPrefs:
underlined: bool = field(
default=False, description="Cache underlined (private) modules")
memory: bool = field(default=None, description="Cache in memory instead of disk")
parallel: bool = field(default=True, description="Use multiple processes to parse")
Copy link
Member

@lieryan lieryan Jan 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this further on the config. A couple thoughts:

  • I don't think parallel should be user-configurable. I'd have preferred to not make parallelization configurable at all, but if there's use case single-threaded mode, I think it's generally the editor/IDE integrators that would need to choose the best value for their integration, not the user. It's generally going to be a matter of whether their environment supports/allows multithreading.

    • Maybe we can make some of these hidden/advanced settings, or add warnings that users generally shouldn't be toggling them unless directed by a rope/integration developer
  • I'm not quite sure whether memory should be global or autoimport-specific configuration. While this setting currently only affects autoimport, we may want to expand this to other caches. Users that don't want rope to create extra files in the project directory likely would want to just turn off all cached files and users that are fine with .ropeproject folder mostly wouldn't care about the different cache files that rope generates.

    • It's only a small amount of very advanced users or users that are having very specific problems that would've cared to vary the memory per internal rope component. Unless there's a strong use case for per-component fine tuning here, I would rather keep things that can be configured to a minimum to keep maintenance simpler.



@dataclass
class Prefs:
"""Class to store rope preferences."""
Expand Down Expand Up @@ -206,6 +214,8 @@ class Prefs:
Can only be set in config.py.
"""),
)
autoimport: AutoimportPrefs = field(
default_factory=AutoimportPrefs, description="Preferences for Autoimport")

def set(self, key: str, value: Any):
"""Set the value of `key` preference to `value`."""
Expand Down
5 changes: 4 additions & 1 deletion rope/base/versioning.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dataclasses
import hashlib
import importlib.util
import json
Expand Down Expand Up @@ -31,7 +32,9 @@ def _get_prefs_data(project) -> str:
del prefs_data["project_opened"]
del prefs_data["callbacks"]
del prefs_data["dependencies"]
return json.dumps(prefs_data, sort_keys=True, indent=2)
return json.dumps(
prefs_data, sort_keys=True, indent=2, default=lambda o: o.__dict__
)


def _get_file_content(module_name: str) -> str:
Expand Down
72 changes: 52 additions & 20 deletions rope/contrib/autoimport/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
from threading import local
from typing import Generator, Iterable, Iterator, List, Optional, Set, Tuple

from packaging.requirements import Requirement
from copy import deepcopy
from rope.base import exceptions, libutils, resourceobserver, taskhandle, versioning
from rope.base.prefs import AutoimportPrefs
from rope.base.project import Project
from rope.base.resources import Resource
from rope.contrib.autoimport import models
Expand Down Expand Up @@ -53,18 +56,36 @@ def get_future_names(


def filter_packages(
packages: Iterable[Package], underlined: bool, existing: List[str]
packages: Iterable[Package],
underlined: bool,
existing: List[str],
dependencies: Optional[List[Requirement]],
) -> Iterable[Package]:
"""Filter list of packages to parse."""
parsed_deps = (
[dep.name for dep in dependencies] if dependencies is not None else None
)

def is_dep(package) -> bool:
return (
parsed_deps is None
or package.name in parsed_deps
or package.source is not Source.SITE_PACKAGE
)

if underlined:

def filter_package(package: Package) -> bool:
return package.name not in existing
return package.name not in existing and is_dep(package)

else:

def filter_package(package: Package) -> bool:
return package.name not in existing and not package.name.startswith("_")
return (
package.name not in existing
and not package.name.startswith("_")
and is_dep(package)
)

return filter(filter_package, packages)

Expand All @@ -81,16 +102,15 @@ class AutoImport:
"""

connection: sqlite3.Connection
memory: bool
project: Project
project_package: Package
underlined: bool
prefs: AutoimportPrefs

def __init__(
self,
project: Project,
observe: bool = True,
underlined: bool = False,
underlined: Optional[bool] = None,
memory: bool = _deprecated_default,
):
"""Construct an AutoImport object.
Expand All @@ -113,25 +133,28 @@ def __init__(
autoimport = AutoImport(..., memory=True)
"""
self.project = project
self.prefs = deepcopy(self.project.prefs.autoimport)
project_package = get_package_tuple(project.root.pathlib, project)
assert project_package is not None
assert project_package.path is not None
if underlined is not None:
self.prefs.underlined = underlined
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need to copy the self.prefs here if we're going to mutate it? Otherwise it would change the setting globally and in other instances of AutoImport that shares the same prefs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - I ran a deep copy. Maybe in the future, it should automatically reload on pyproject.toml changes? Would you want me to include that in this PR?

self.project_package = project_package
self.underlined = underlined
self.memory = memory
if memory is _deprecated_default:
self.memory = True
self.prefs.memory = True
warnings.warn(
"The default value for `AutoImport(memory)` argument will "
"change to use an on-disk database by default in the future. "
"If you want to use an in-memory database, you need to pass "
"`AutoImport(memory=True)` explicitly.",
"`AutoImport(memory=True)` explicitly or set it in the config file.",
DeprecationWarning,
)
else:
self.prefs.memory = memory
self.thread_local = local()
self.connection = self.create_database_connection(
project=project,
memory=memory,
memory=self.prefs.memory,
)
self._setup_db()
if observe:
Expand Down Expand Up @@ -187,7 +210,7 @@ def connection(self):
if not hasattr(self.thread_local, "connection"):
self.thread_local.connection = self.create_database_connection(
project=self.project,
memory=self.memory,
memory=self.prefs.memory,
)
return self.thread_local.connection

Expand Down Expand Up @@ -389,12 +412,13 @@ def generate_modules_cache(
"""
Generate global name cache for external modules listed in `modules`.

If no modules are provided, it will generate a cache for every module available.
If modules is not specified, uses PEP 621 metadata.
If modules aren't specified and PEP 621 is not present, caches every package
This method searches in your sys.path and configured python folders.
Do not use this for generating your own project's internal names,
use generate_resource_cache for that instead.
"""
underlined = self.underlined if underlined is None else underlined
underlined = self.prefs.underlined if underlined is None else underlined

packages: List[Package] = (
self._get_available_packages()
Expand All @@ -403,12 +427,16 @@ def generate_modules_cache(
)

existing = self._get_packages_from_cache()
packages = list(filter_packages(packages, underlined, existing))
if not packages:
packages = list(
filter_packages(
packages, underlined, existing, self.project.prefs.dependencies
)
)
if len(packages) == 0:
return
self._add_packages(packages)
job_set = task_handle.create_jobset("Generating autoimport cache", 0)
if single_thread:
if single_thread or not self.prefs.parallel:
for package in packages:
for module in get_files(package, underlined):
job_set.started_job(module.modname)
Expand Down Expand Up @@ -512,7 +540,7 @@ def update_resource(
self, resource: Resource, underlined: bool = False, commit: bool = True
):
"""Update the cache for global names in `resource`."""
underlined = underlined if underlined else self.underlined
underlined = underlined if underlined else self.prefs.underlined
module = self._resource_to_module(resource, underlined)
self._del_if_exist(module_name=module.modname, commit=False)
for name in get_names(module, self.project_package):
Expand All @@ -537,7 +565,11 @@ def _del_if_exist(self, module_name, commit: bool = True):

def _get_python_folders(self) -> List[Path]:
def filter_folders(folder: Path) -> bool:
return folder.is_dir() and folder.as_posix() != "/usr/bin"
return (
folder.is_dir()
and folder.as_posix() != "/usr/bin"
and str(folder) != self.project.address
)

folders = self.project.get_python_path_folders()
folder_paths = filter(filter_folders, map(Path, folders))
Expand Down Expand Up @@ -623,7 +655,7 @@ def _resource_to_module(
self, resource: Resource, underlined: bool = False
) -> ModuleFile:
assert self.project_package.path
underlined = underlined if underlined else self.underlined
underlined = underlined if underlined else self.prefs.underlined
resource_path: Path = resource.pathlib
# The project doesn't need its name added to the path,
# since the standard python file layout accounts for that
Expand Down
4 changes: 3 additions & 1 deletion rope/contrib/autoimport/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ def get_files(
yield ModuleFile(package.path, package.path.stem, underlined, False)
else:
assert package.path
for file in package.path.glob("**/*.py"):
for file in package.path.rglob("*.py"):
if "site-packages" in file.relative_to(package.path).parts:
continue
if file.name == "__init__.py":
yield ModuleFile(
file,
Expand Down
6 changes: 4 additions & 2 deletions ropetest/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@


@pytest.fixture
def project():
project = testutils.sample_project()
def project(request):
pyproject = request.param if hasattr(request, "param") else None
project = testutils.sample_project(pyproject=pyproject)
yield project
testutils.remove_project(project)

Expand Down Expand Up @@ -39,6 +40,7 @@ def project2():
/pkg1/mod2.py -- mod2
"""


@pytest.fixture
def mod1(project) -> resources.File:
return testutils.create_module(project, "mod1")
Expand Down
8 changes: 4 additions & 4 deletions ropetest/contrib/autoimport/autoimporttest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

@pytest.fixture
def autoimport(project: Project):
with closing(AutoImport(project)) as ai:
with closing(AutoImport(project, memory=True)) as ai:
yield ai


Expand Down Expand Up @@ -124,9 +124,9 @@ def foo():


def test_connection(project: Project, project2: Project):
ai1 = AutoImport(project)
ai2 = AutoImport(project)
ai3 = AutoImport(project2)
ai1 = AutoImport(project, memory=True)
ai2 = AutoImport(project, memory=True)
ai3 = AutoImport(project2, memory=True)

assert ai1.connection is not ai2.connection
assert ai1.connection is not ai3.connection
Expand Down
40 changes: 40 additions & 0 deletions ropetest/contrib/autoimport/deptest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Iterable

import pytest

from rope.contrib.autoimport.sqlite import AutoImport


@pytest.fixture
def autoimport(project) -> Iterable[AutoImport]:
autoimport = AutoImport(project, memory=True)
autoimport.generate_modules_cache()
yield autoimport
autoimport.close()


@pytest.mark.parametrize("project", ((""),), indirect=True)
def test_blank(project, autoimport):
assert project.prefs.dependencies is None
assert autoimport.search("pytoolconfig")


@pytest.mark.parametrize("project", (("[project]\n dependencies=[]"),), indirect=True)
def test_empty(project, autoimport):
assert len(project.prefs.dependencies) == 0
assert [] == autoimport.search("pytoolconfig")


FILE = """
[project]
dependencies = [
"pytoolconfig",
"bogus"
]
"""


@pytest.mark.parametrize("project", ((FILE),), indirect=True)
def test_not_empty(project, autoimport):
assert len(project.prefs.dependencies) == 2
assert autoimport.search("pytoolconfig")
11 changes: 6 additions & 5 deletions ropetest/contrib/autoimporttest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def setUp(self):
self.mod1 = testutils.create_module(self.project, "mod1")
self.pkg = testutils.create_package(self.project, "pkg")
self.mod2 = testutils.create_module(self.project, "mod2", self.pkg)
self.importer = autoimport.AutoImport(self.project, observe=False)
self.importer = autoimport.AutoImport(self.project, observe=False, memory=True)

def tearDown(self):
testutils.remove_project(self.project)
Expand Down Expand Up @@ -86,7 +86,7 @@ def test_not_caching_underlined_names(self):
self.assertEqual(["mod1"], self.importer.get_modules("_myvar"))

def test_caching_underlined_names_passing_to_the_constructor(self):
importer = autoimport.AutoImport(self.project, False, True)
importer = autoimport.AutoImport(self.project, False, True, memory=True)
self.mod1.write("_myvar = None\n")
importer.update_resource(self.mod1)
self.assertEqual(["mod1"], importer.get_modules("_myvar"))
Expand Down Expand Up @@ -146,9 +146,10 @@ def test_skipping_directories_not_accessible_because_of_permission_error(self):
# The single thread test takes much longer than the multithread test but is easier to debug
single_thread = False
self.importer.generate_modules_cache(single_thread=single_thread)

# Create a temporary directory and set permissions to 000
import tempfile, sys
import sys
import tempfile
with tempfile.TemporaryDirectory() as dir:
import os
os.chmod(dir, 0o000)
Expand All @@ -165,7 +166,7 @@ def setUp(self):
self.mod1 = testutils.create_module(self.project, "mod1")
self.pkg = testutils.create_package(self.project, "pkg")
self.mod2 = testutils.create_module(self.project, "mod2", self.pkg)
self.importer = autoimport.AutoImport(self.project, observe=True)
self.importer = autoimport.AutoImport(self.project, observe=True, memory=True)

def tearDown(self):
testutils.remove_project(self.project)
Expand Down
Loading
Loading