From f3311685a90018c01d61046fda25b5155ee7abcb Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 19:42:11 +1100 Subject: [PATCH 01/11] Make _Project.close() an abstractmethod and move warning message to NoProject.close() --- rope/base/project.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index ea19722ac..f78a8c2c2 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -5,6 +5,7 @@ import os import sys import warnings +from abc import ABC, abstractmethod from contextlib import ExitStack from typing import Optional @@ -24,7 +25,7 @@ import pickle # type: ignore -class _Project: +class _Project(ABC): prefs: Prefs def __init__(self, fscommands): @@ -185,8 +186,9 @@ def history(self): def pycore(self): return pycore.PyCore(self) + @abstractmethod def close(self): - warnings.warn("Cannot close a NoProject", DeprecationWarning, stacklevel=2) + """Closes project open resources""" ropefolder = None @@ -334,6 +336,9 @@ def get_files(self): def get_python_files(self): return [] + def close(self): + warnings.warn("Cannot close a NoProject", DeprecationWarning, stacklevel=2) + _no_project = None From c1a01dba41959fe2a25eb7ea4cb85d2871a066fc Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 20:08:22 +1100 Subject: [PATCH 02/11] Improve typing behavior for NoProject/Project.ropefolder --- rope/base/project.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index f78a8c2c2..4a9b86f9b 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -190,7 +190,11 @@ def pycore(self): def close(self): """Closes project open resources""" - ropefolder = None + @property + @abstractmethod + def ropefolder(self) -> Optional[Folder]: + # FIXME: use class-level ReadOnly attribute when they become available (PEP-767) + ... class Project(_Project): @@ -299,9 +303,10 @@ def set(self, key, value): self.prefs.set(key, value) @property - def ropefolder(self): + def ropefolder(self) -> Optional[Folder]: if self._ropefolder_name is not None: return self.get_folder(self._ropefolder_name) + return None def validate(self, folder=None): if folder is None: @@ -318,6 +323,8 @@ class NoProject(_Project): This class is singleton use `get_no_project` global function """ + ropefolder: Optional[Folder] = None + def __init__(self): fscommands = rope.base.fscommands.FileSystemCommands() super().__init__(fscommands) From 97211ae2dc82307519e85eb506ba69680b262930 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 20:16:41 +1100 Subject: [PATCH 03/11] Add typing for NoProject._no_project attribute --- rope/base/project.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index 4a9b86f9b..f004da3e9 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -323,6 +323,7 @@ class NoProject(_Project): This class is singleton use `get_no_project` global function """ + _no_project: Optional[NoProject] = None ropefolder: Optional[Folder] = None def __init__(self): @@ -346,10 +347,8 @@ def get_python_files(self): def close(self): warnings.warn("Cannot close a NoProject", DeprecationWarning, stacklevel=2) - _no_project = None - -def get_no_project(): +def get_no_project() -> NoProject: if NoProject._no_project is None: NoProject._no_project = NoProject() return NoProject._no_project From d24b66d39891d1022bd064440495d2ba525aaed0 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 20:20:52 +1100 Subject: [PATCH 04/11] Add typing for _Project._get_resource_path() and make it abstractmethod --- rope/base/project.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index f004da3e9..9f628c619 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -173,9 +173,6 @@ def find_relative_module(self, modname, folder, level): def is_ignored(self, resource): return False - def _get_resource_path(self, name): - pass - @property @utils.saveit def history(self): @@ -190,6 +187,9 @@ def pycore(self): def close(self): """Closes project open resources""" + @abstractmethod + def _get_resource_path(self, name: str) -> str: ... + @property @abstractmethod def ropefolder(self) -> Optional[Folder]: @@ -259,7 +259,7 @@ def get_python_files(self): if self.pycore.is_python_file(resource) ] - def _get_resource_path(self, name): + def _get_resource_path(self, name: str) -> str: return os.path.join(self._address, *name.split("/")) def _init_ropefolder(self): @@ -330,7 +330,7 @@ def __init__(self): fscommands = rope.base.fscommands.FileSystemCommands() super().__init__(fscommands) - def _get_resource_path(self, name): + def _get_resource_path(self, name: str) -> str: real_name = name.replace("/", os.path.sep) return _realpath(real_name) From 9b39aeee3a2ab2bd7e1867172c2c1e801129cb20 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 20:40:38 +1100 Subject: [PATCH 05/11] Make _Project.get_files()/get_python_files() into abstractmethod And also add typing information. --- rope/base/project.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index 9f628c619..07a8859a3 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -17,7 +17,7 @@ # At present rope.base.prefs starts with `# type:ignore`. # As a result, mypy knows nothing about Prefs and get_config. from rope.base.prefs import Prefs, get_config # type: ignore -from rope.base.resources import File, Folder, _ResourceMatcher +from rope.base.resources import File, Folder, Resource, _ResourceMatcher try: import cPickle as pickle # type: ignore @@ -187,19 +187,29 @@ def pycore(self): def close(self): """Closes project open resources""" - @abstractmethod - def _get_resource_path(self, name: str) -> str: ... - @property @abstractmethod def ropefolder(self) -> Optional[Folder]: # FIXME: use class-level ReadOnly attribute when they become available (PEP-767) ... + @abstractmethod + def _get_resource_path(self, name: str) -> str: ... + + @abstractmethod + def get_files(self) -> list[Resource]: + return [] + + @abstractmethod + def get_python_files(self) -> list[Resource]: + return [] + class Project(_Project): """A Project containing files and folders""" + file_list: _FileListCacher + def __init__( self, projectroot, fscommands=None, ropefolder=".ropeproject", **prefs ): @@ -248,10 +258,10 @@ def _init_source_folders(self): folder = self.get_resource(path) self._custom_source_folders.append(folder) - def get_files(self): - return self.file_list.get_files() + def get_files(self) -> list[Resource]: + return list(self.file_list.get_files()) - def get_python_files(self): + def get_python_files(self) -> list[Resource]: """Returns all python files available in the project""" return [ resource @@ -338,10 +348,10 @@ def get_resource(self, name): universal_name = _realpath(name).replace(os.path.sep, "/") return super().get_resource(universal_name) - def get_files(self): + def get_files(self) -> list[Resource]: return [] - def get_python_files(self): + def get_python_files(self) -> list[Resource]: return [] def close(self): @@ -355,6 +365,8 @@ def get_no_project() -> NoProject: class _FileListCacher: + files: set[Resource] + def __init__(self, project): self.project = project self.files = None @@ -363,7 +375,7 @@ def __init__(self, project): ) self.project.add_observer(rawobserver) - def get_files(self): + def get_files(self) -> set[Resource]: if self.files is None: self.files = set() self._add_files(self.project.root) From 0339b32cbe8c0d544009c0a3948d6f30cb021c94 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 21:49:45 +1100 Subject: [PATCH 06/11] Move _Project.get_module()/get_source_folders()/find_module() down to Project These functions depends on Project.root attribute, which only exists in Project anyway. They do not make sense in NoProject or _Project. Also add type annotations. --- rope/base/project.py | 96 ++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index 07a8859a3..a10848a1d 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -11,7 +11,7 @@ import rope.base.fscommands # Use full qualification for clarity. import rope.base.resourceobserver as resourceobserver -from rope.base import exceptions, history, pycore, taskhandle, utils +from rope.base import exceptions, history, pycore, pyobjectsdef, taskhandle, utils from rope.base.exceptions import ModuleNotFoundError # At present rope.base.prefs starts with `# type:ignore`. @@ -58,17 +58,6 @@ def get_resource(self, resource_name): else: raise exceptions.ResourceNotFoundError("Unknown resource " + resource_name) - def get_module(self, name, folder=None): - """Returns a `PyObject` if the module was found.""" - # check if this is a builtin module - pymod = self.pycore.builtin_module(name) - if pymod is not None: - return pymod - module = self.find_module(name, folder) - if module is None: - raise ModuleNotFoundError("Module %s not found" % name) - return self.pycore.resource_to_pyobject(module) - def get_python_path_folders(self): result = [] for src in self.prefs.get("python_path", []) + sys.path: @@ -77,19 +66,6 @@ def get_python_path_folders(self): result.append(src_folder) return result - # INFO: It was decided not to cache source folders, since: - # - Does not take much time when the root folder contains - # packages, that is most of the time - # - We need a separate resource observer; `self.observer` - # does not get notified about module and folder creations - def get_source_folders(self): - """Returns project source folders""" - if self.root is None: - return [] - result = list(self._custom_source_folders) - result.extend(self.pycore._find_source_folders(self.root)) - return result - def validate(self, folder): """Validate files and folders contained in this folder @@ -143,25 +119,6 @@ def get_relative_module(self, name, folder, level): raise ModuleNotFoundError("Module %s not found" % name) return self.pycore.resource_to_pyobject(module) - def find_module(self, modname, folder=None) -> Optional[File]: - """Returns a resource corresponding to the given module - - returns None if it can not be found - """ - for src in self.get_source_folders(): - module = _find_module_in_folder(src, modname) - if module is not None: - return module - for src in self.get_python_path_folders(): - module = _find_module_in_folder(src, modname) - if module is not None: - return module - if folder is not None: - module = _find_module_in_folder(folder, modname) - if module is not None: - return module - return None - def find_relative_module(self, modname, folder, level): for i in range(level - 1): folder = folder.parent @@ -258,6 +215,57 @@ def _init_source_folders(self): folder = self.get_resource(path) self._custom_source_folders.append(folder) + # INFO: It was decided not to cache source folders, since: + # - Does not take much time when the root folder contains + # packages, that is most of the time + # - We need a separate resource observer; `self.observer` + # does not get notified about module and folder creations + def get_source_folders(self) -> list[Folder]: + """Returns project source folders""" + if self.root is None: + return [] + result = list(self._custom_source_folders) + result.extend(self.pycore._find_source_folders(self.root)) + return result + + def find_module( + self, + modname: str, + folder: Optional[Folder] = None, + ) -> Optional[Resource]: + """Returns a resource corresponding to the given module + + returns None if it can not be found + """ + for src in self.get_source_folders(): + module = _find_module_in_folder(src, modname) + if module is not None: + return module + for src in self.get_python_path_folders(): + module = _find_module_in_folder(src, modname) + if module is not None: + return module + if folder is not None: + module = _find_module_in_folder(folder, modname) + if module is not None: + return module + return None + + def get_module( + self, + name: str, + folder: Optional[Folder] = None, + ) -> pyobjectsdef.PyPackage | pyobjectsdef.PyModule: + """Returns a `PyObject` if the module was found.""" + # check if this is a builtin module + pymod = self.pycore.builtin_module(name) + if pymod is not None: + return pymod + module = self.find_module(name, folder) + if module is None: + raise ModuleNotFoundError("Module %s not found" % name) + return self.pycore.resource_to_pyobject(module) + def get_files(self) -> list[Resource]: return list(self.file_list.get_files()) From b4301776f509dbc4743dc3d02baea6113907c2a2 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 23:13:27 +1100 Subject: [PATCH 07/11] Add type annotations to a number of file-related functions in project.py --- rope/base/project.py | 37 +++++++++++++++++++++++++------------ rope/base/pycore.py | 6 +++++- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index a10848a1d..8e79891a5 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -27,6 +27,8 @@ class _Project(ABC): prefs: Prefs + data_files: _DataFiles + _custom_source_folders: list[Folder] def __init__(self, fscommands): self.observers = [] @@ -35,7 +37,7 @@ def __init__(self, fscommands): self.data_files = _DataFiles(self) self._custom_source_folders = [] - def get_resource(self, resource_name): + def get_resource(self, resource_name: str) -> Resource: """Get a resource in a project. `resource_name` is the path of a resource in a project. It is @@ -58,7 +60,7 @@ def get_resource(self, resource_name): else: raise exceptions.ResourceNotFoundError("Unknown resource " + resource_name) - def get_python_path_folders(self): + def get_python_path_folders(self) -> list[Folder]: result = [] for src in self.prefs.get("python_path", []) + sys.path: with contextlib.suppress(exceptions.ResourceNotFoundError): @@ -102,24 +104,34 @@ def get_pymodule(self, resource, force_errors=False): def get_pycore(self): return self.pycore - def get_file(self, path): + def get_file(self, path: str) -> File: """Get the file with `path` (it may not exist)""" return File(self, path) - def get_folder(self, path): + def get_folder(self, path: str) -> Folder: """Get the folder with `path` (it may not exist)""" return Folder(self, path) def get_prefs(self): return self.prefs - def get_relative_module(self, name, folder, level): + def get_relative_module( + self, + name: str, + folder: Folder, + level: int, + ) -> pyobjectsdef.PyPackage | pyobjectsdef.PyModule: module = self.find_relative_module(name, folder, level) if module is None: raise ModuleNotFoundError("Module %s not found" % name) return self.pycore.resource_to_pyobject(module) - def find_relative_module(self, modname, folder, level): + def find_relative_module( + self, + modname: str, + folder: Folder, + level: int, + ) -> Optional[Resource]: for i in range(level - 1): folder = folder.parent if modname == "": @@ -137,7 +149,7 @@ def history(self): @property @utils.saveit - def pycore(self): + def pycore(self) -> pycore.PyCore: return pycore.PyCore(self) @abstractmethod @@ -336,9 +348,9 @@ def validate(self, folder=None): class NoProject(_Project): - """A null object for holding out of project files. + """A null object for holding out-of-project files. - This class is singleton use `get_no_project` global function + This class is a singleton, use `get_no_project` global function. """ _no_project: Optional[NoProject] = None @@ -352,7 +364,7 @@ def _get_resource_path(self, name: str) -> str: real_name = name.replace("/", os.path.sep) return _realpath(real_name) - def get_resource(self, name): + def get_resource(self, name: str) -> Resource: universal_name = _realpath(name).replace(os.path.sep, "/") return super().get_resource(universal_name) @@ -447,7 +459,7 @@ def _get_file(self, name): return self.project.get_file(path) -def _realpath(path): +def _realpath(path: str) -> str: """Return the real path of `path` Is equivalent to ``realpath(abspath(expanduser(path)))``. @@ -468,7 +480,7 @@ def _realpath(path): return os.path.realpath(os.path.abspath(os.path.expanduser(path))) -def _find_module_in_folder(folder, modname): +def _find_module_in_folder(folder: Folder, modname: str) -> Optional[Resource]: module = folder packages = modname.split(".") for pkg in packages[:-1]: @@ -487,3 +499,4 @@ def _find_module_in_folder(folder, modname): and not module.get_child(packages[-1] + ".py").is_folder() ): return module.get_child(packages[-1] + ".py") + return None diff --git a/rope/base/pycore.py b/rope/base/pycore.py index 4d9cd5ccf..a0f70c7cd 100644 --- a/rope/base/pycore.py +++ b/rope/base/pycore.py @@ -131,7 +131,11 @@ def get_source_folders(self): """Returns project source folders""" return self.project.get_source_folders() - def resource_to_pyobject(self, resource, force_errors=False): + def resource_to_pyobject( + self, + resource, + force_errors=False, + ) -> pyobjectsdef.PyPackage | pyobjectsdef.PyModule: return self.module_cache.get_pymodule(resource, force_errors) @utils.deprecated("Use `project.get_python_files` instead") From bf9e70b4cba84f4deb7183104f531deeb8866053 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Mon, 26 Oct 2026 23:13:59 +1100 Subject: [PATCH 08/11] Add remark about possible missing validation in python_path --- rope/base/project.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rope/base/project.py b/rope/base/project.py index 8e79891a5..444ac2bfb 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -7,7 +7,7 @@ import warnings from abc import ABC, abstractmethod from contextlib import ExitStack -from typing import Optional +from typing import Optional, cast import rope.base.fscommands # Use full qualification for clarity. import rope.base.resourceobserver as resourceobserver @@ -65,6 +65,11 @@ def get_python_path_folders(self) -> list[Folder]: for src in self.prefs.get("python_path", []) + sys.path: with contextlib.suppress(exceptions.ResourceNotFoundError): src_folder = get_no_project().get_resource(src) + + # FIXME: it shouldn't be supported, but what if someone + # provided a .py file in python_path? + src_folder = cast(Folder, src_folder) + result.append(src_folder) return result From ca3bb6e783499db10cd00a23b95645012a337831 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Fri, 6 Nov 2026 03:25:54 +1100 Subject: [PATCH 09/11] Black --- rope/base/oi/doa.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rope/base/oi/doa.py b/rope/base/oi/doa.py index 1fbce3210..801c257e5 100644 --- a/rope/base/oi/doa.py +++ b/rope/base/oi/doa.py @@ -8,7 +8,6 @@ import sys import threading - try: import cPickle as pickle # type: ignore except ImportError: From 0c5a06fe5ac4845a4d57f027cf0b874024dbce67 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Fri, 6 Nov 2026 03:28:40 +1100 Subject: [PATCH 10/11] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a245852..e36916984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - #895 patchedast Starred and keyword now consumes their syntactically expected and ** (@lieryan) - #896 patchedast cleanup and refactoring (@lieryan) - #905 Ignore `node_modules` directories by default when scanning project files and source folders. (@marcoeangeli) +- #933 Add type annotations to project.py (@lieryan) # Release 1.15.1 From 6c3f8b2d9ed43caa47a9e86f35757fa1be103c58 Mon Sep 17 00:00:00 2001 From: Lie Ryan Date: Fri, 6 Nov 2026 03:36:17 +1100 Subject: [PATCH 11/11] Resolve circular import --- rope/base/project.py | 8 ++++++-- rope/base/pycore.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/rope/base/project.py b/rope/base/project.py index 444ac2bfb..c7f6a2f8e 100644 --- a/rope/base/project.py +++ b/rope/base/project.py @@ -7,11 +7,11 @@ import warnings from abc import ABC, abstractmethod from contextlib import ExitStack -from typing import Optional, cast +from typing import TYPE_CHECKING, Optional, cast import rope.base.fscommands # Use full qualification for clarity. import rope.base.resourceobserver as resourceobserver -from rope.base import exceptions, history, pycore, pyobjectsdef, taskhandle, utils +from rope.base import exceptions, history, pycore, taskhandle, utils from rope.base.exceptions import ModuleNotFoundError # At present rope.base.prefs starts with `# type:ignore`. @@ -25,6 +25,10 @@ import pickle # type: ignore +if TYPE_CHECKING: + from rope.base import pyobjectsdef + + class _Project(ABC): prefs: Prefs data_files: _DataFiles diff --git a/rope/base/pycore.py b/rope/base/pycore.py index a0f70c7cd..a6d6041cd 100644 --- a/rope/base/pycore.py +++ b/rope/base/pycore.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import bisect import contextlib import difflib