diff --git a/CHANGELOG.md b/CHANGELOG.md index 9734f518b..4bb62f3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Find out more about isort's release policy [here](https://pycqa.github.io/isort/ - Fixed #1523: in rare cases isort can ignore direct from import if as import is also on same line. Potentially breaking changes: + - Fixed #1443: Incorrect third vs first party categorization - namespace packages. - Fixed #1486: "Google" profile is not quite Google style. - Fixed "PyCharm" profile to always add 2 lines to be consistent with what PyCharm "Optimize Imports" does. diff --git a/isort/place.py b/isort/place.py index 117428aeb..c85cf83b6 100644 --- a/isort/place.py +++ b/isort/place.py @@ -3,7 +3,7 @@ from fnmatch import fnmatch from functools import lru_cache from pathlib import Path -from typing import Optional, Tuple +from typing import FrozenSet, Iterable, Optional, Tuple from isort import sections from isort.settings import DEFAULT_CONFIG, Config @@ -60,10 +60,31 @@ def _known_pattern(name: str, config: Config) -> Optional[Tuple[str, str]]: return None -def _src_path(name: str, config: Config) -> Optional[Tuple[str, str]]: - for src_path in config.src_paths: - root_module_name = name.split(".")[0] +def _src_path( + name: str, + config: Config, + src_paths: Optional[Iterable[Path]] = None, + prefix: Tuple[str, ...] = (), +) -> Optional[Tuple[str, str]]: + if src_paths is None: + src_paths = config.src_paths + + root_module_name, *nested_module = name.split(".", 1) + new_prefix = prefix + (root_module_name,) + namespace = ".".join(new_prefix) + + for src_path in src_paths: module_path = (src_path / root_module_name).resolve() + if not prefix and not module_path.is_dir() and src_path.name == root_module_name: + module_path = src_path.resolve() + if nested_module and ( + namespace in config.namespace_packages + or ( + config.auto_identify_namespace_packages + and _is_namespace_package(module_path, config.supported_extensions) + ) + ): + return _src_path(nested_module[0], config, (module_path,), new_prefix) if ( _is_module(module_path) or _is_package(module_path) @@ -89,6 +110,32 @@ def _is_package(path: Path) -> bool: return exists_case_sensitive(str(path)) and path.is_dir() +def _is_namespace_package(path: Path, src_extensions: FrozenSet[str]) -> bool: + if not _is_package(path): + return False + + init_file = path / "__init__.py" + if not init_file.exists(): + filenames = [ + filename for filename in path.iterdir() if filename.suffix.lstrip(".") in src_extensions + ] + if filenames: + return False + else: + with init_file.open() as open_init_file: + file_start = open_init_file.read(4096) + if ( + "__import__('pkg_resources').declare_namespace(__name__)" not in file_start + and '__import__("pkg_resources").declare_namespace(__name__)' not in file_start + and "__path__ = __import__('pkgutil').extend_path(__path__, __name__)" + not in file_start + and '__path__ = __import__("pkgutil").extend_path(__path__, __name__)' + not in file_start + ): + return False + return True + + def _src_path_is_module(src_path: Path, module_name: str) -> bool: return ( module_name == src_path.name and src_path.is_dir() and exists_case_sensitive(str(src_path)) diff --git a/isort/settings.py b/isort/settings.py index a89f6d69f..e80a989dc 100644 --- a/isort/settings.py +++ b/isort/settings.py @@ -200,6 +200,8 @@ class _Config: dedup_headings: bool = False only_sections: bool = False only_modified: bool = False + auto_identify_namespace_packages: bool = True + namespace_packages: FrozenSet[str] = frozenset() def __post_init__(self): py_version = self.py_version diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 09e4acbdb..1e0bd9dfa 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -26,3 +26,8 @@ def test_path(): @pytest.fixture def src_path(): return Path(SRC_DIR).resolve() + + +@pytest.fixture +def examples_path(): + return Path(TEST_DIR).resolve() / "example_projects" diff --git a/tests/unit/example_projects/namespaces/almost-implicit/.isort.cfg b/tests/unit/example_projects/namespaces/almost-implicit/.isort.cfg new file mode 100644 index 000000000..d3ae4c35a --- /dev/null +++ b/tests/unit/example_projects/namespaces/almost-implicit/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +src_paths=root diff --git a/tests/unit/example_projects/namespaces/almost-implicit/root/nested/__init__.py b/tests/unit/example_projects/namespaces/almost-implicit/root/nested/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/almost-implicit/root/nested/x.py b/tests/unit/example_projects/namespaces/almost-implicit/root/nested/x.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/almost-implicit/root/y.py b/tests/unit/example_projects/namespaces/almost-implicit/root/y.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/implicit/.isort.cfg b/tests/unit/example_projects/namespaces/implicit/.isort.cfg new file mode 100644 index 000000000..d3ae4c35a --- /dev/null +++ b/tests/unit/example_projects/namespaces/implicit/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +src_paths=root diff --git a/tests/unit/example_projects/namespaces/implicit/root/nested/__init__.py b/tests/unit/example_projects/namespaces/implicit/root/nested/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/implicit/root/nested/x.py b/tests/unit/example_projects/namespaces/implicit/root/nested/x.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/none/.isort.cfg b/tests/unit/example_projects/namespaces/none/.isort.cfg new file mode 100644 index 000000000..d3ae4c35a --- /dev/null +++ b/tests/unit/example_projects/namespaces/none/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +src_paths=root diff --git a/tests/unit/example_projects/namespaces/none/root/__init__.py b/tests/unit/example_projects/namespaces/none/root/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/none/root/nested/__init__.py b/tests/unit/example_projects/namespaces/none/root/nested/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/pkg_resource/.isort.cfg b/tests/unit/example_projects/namespaces/pkg_resource/.isort.cfg new file mode 100644 index 000000000..d3ae4c35a --- /dev/null +++ b/tests/unit/example_projects/namespaces/pkg_resource/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +src_paths=root diff --git a/tests/unit/example_projects/namespaces/pkg_resource/root/__init__.py b/tests/unit/example_projects/namespaces/pkg_resource/root/__init__.py new file mode 100644 index 000000000..5284146eb --- /dev/null +++ b/tests/unit/example_projects/namespaces/pkg_resource/root/__init__.py @@ -0,0 +1 @@ +__import__("pkg_resources").declare_namespace(__name__) diff --git a/tests/unit/example_projects/namespaces/pkg_resource/root/nested/__init__.py b/tests/unit/example_projects/namespaces/pkg_resource/root/nested/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/pkg_resource/root/nested/x.py b/tests/unit/example_projects/namespaces/pkg_resource/root/nested/x.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/pkgutil/.isort.cfg b/tests/unit/example_projects/namespaces/pkgutil/.isort.cfg new file mode 100644 index 000000000..d3ae4c35a --- /dev/null +++ b/tests/unit/example_projects/namespaces/pkgutil/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +src_paths=root diff --git a/tests/unit/example_projects/namespaces/pkgutil/root/__init__.py b/tests/unit/example_projects/namespaces/pkgutil/root/__init__.py new file mode 100644 index 000000000..8db66d3d0 --- /dev/null +++ b/tests/unit/example_projects/namespaces/pkgutil/root/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/tests/unit/example_projects/namespaces/pkgutil/root/nested/__init__.py b/tests/unit/example_projects/namespaces/pkgutil/root/nested/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/example_projects/namespaces/pkgutil/root/nested/x.py b/tests/unit/example_projects/namespaces/pkgutil/root/nested/x.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_place.py b/tests/unit/test_place.py index b1159471f..18ac7fccd 100644 --- a/tests/unit/test_place.py +++ b/tests/unit/test_place.py @@ -27,3 +27,30 @@ def test_no_standard_library_placement(): "pathlib", config=Config(sections=["THIRDPARTY"], default_section="THIRDPARTY") ) == ("THIRDPARTY", "Default option in Config or universal default.") assert place.module("pathlib") == "STDLIB" + + +def test_namespace_package_placement(examples_path): + namespace_examples = examples_path / "namespaces" + + implicit = namespace_examples / "implicit" + pkg_resource = namespace_examples / "pkg_resource" + pkgutil = namespace_examples / "pkgutil" + for namespace_test in (implicit, pkg_resource, pkgutil): + print(namespace_test) + config = Config(settings_path=namespace_test) + no_namespaces = Config(settings_path=namespace_test, auto_identify_namespace_packages=False) + namespace_override = Config(settings_path=namespace_test, known_firstparty=["root.name"]) + assert place.module("root.name", config=config) == "THIRDPARTY" + assert place.module("root.nested", config=config) == "FIRSTPARTY" + assert place.module("root.name", config=no_namespaces) == "FIRSTPARTY" + assert place.module("root.name", config=namespace_override) == "FIRSTPARTY" + + no_namespace = namespace_examples / "none" + almost_implicit = namespace_examples / "almost-implicit" + for lacks_namespace in (no_namespace, almost_implicit): + config = Config(settings_path=lacks_namespace) + manual_namespace = Config(settings_path=lacks_namespace, namespace_packages=["root"]) + assert place.module("root.name", config=config) == "FIRSTPARTY" + assert place.module("root.nested", config=config) == "FIRSTPARTY" + assert place.module("root.name", config=manual_namespace) == "THIRDPARTY" + assert place.module("root.nested", config=config) == "FIRSTPARTY"