mirror of
https://github.com/astral-sh/ruff.git
synced 2025-10-26 09:58:17 +00:00
[ty] add legacy namespace package support (#20897)
Some checks are pending
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (macos) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / ty completion evaluation (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / benchmarks walltime (medium|multithreaded) (push) Blocked by required conditions
CI / benchmarks walltime (small|large) (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
Some checks are pending
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (macos) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / ty completion evaluation (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / benchmarks walltime (medium|multithreaded) (push) Blocked by required conditions
CI / benchmarks walltime (small|large) (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
Detect legacy namespace packages and treat them like namespace packages when looking them up as the *parent* of the module we're interested in. In all other cases treat them like a regular package. (This PR is coauthored by @MichaReiser in a shared coding session) Fixes https://github.com/astral-sh/ty/issues/838 --------- Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
parent
96b156303b
commit
64edfb6ef6
2 changed files with 405 additions and 2 deletions
|
|
@ -0,0 +1,221 @@
|
||||||
|
# Legacy namespace packages
|
||||||
|
|
||||||
|
## `__import__("pkgutil").extend_path`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[environment]
|
||||||
|
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`/airflow-core/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||||
|
__version__ = "3.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__version__ = "9.15.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`test.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from airflow import __version__ as airflow_version
|
||||||
|
from airflow.providers.amazon import __version__ as amazon_provider_version
|
||||||
|
|
||||||
|
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
|
||||||
|
reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## `pkgutil.extend_path`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[environment]
|
||||||
|
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`/airflow-core/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||||
|
__version__ = "3.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__version__ = "9.15.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`test.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from airflow import __version__ as airflow_version
|
||||||
|
from airflow.providers.amazon import __version__ as amazon_provider_version
|
||||||
|
|
||||||
|
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
|
||||||
|
reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## `extend_path` with keyword arguments
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[environment]
|
||||||
|
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`/airflow-core/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
__path__ = pkgutil.extend_path(name=__name__, path=__path__)
|
||||||
|
__version__ = "3.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
__path__ = pkgutil.extend_path(name=__name__, path=__path__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
__path__ = pkgutil.extend_path(name=__name__, path=__path__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__version__ = "9.15.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`test.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from airflow import __version__ as airflow_version
|
||||||
|
from airflow.providers.amazon import __version__ as amazon_provider_version
|
||||||
|
|
||||||
|
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
|
||||||
|
reveal_type(amazon_provider_version) # revealed: Literal["9.15.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## incorrect `__import__` arguments
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[environment]
|
||||||
|
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`/airflow-core/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("not_pkgutil").extend_path(__path__, __name__)
|
||||||
|
__version__ = "3.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("not_pkgutil").extend_path(__path__, __name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("not_pkgutil").extend_path(__path__, __name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__version__ = "9.15.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`test.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from airflow.providers.amazon import __version__ as amazon_provider_version # error: [unresolved-import]
|
||||||
|
from airflow import __version__ as airflow_version
|
||||||
|
|
||||||
|
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## incorrect `extend_path` arguments
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[environment]
|
||||||
|
extra-paths = ["/airflow-core/src", "/providers/amazon/src/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`/airflow-core/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("pkgutil").extend_path(__path__, "other_module")
|
||||||
|
__version__ = "3.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("pkgutil").extend_path(__path__, "other_module")
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__path__ = __import__("pkgutil").extend_path(__path__, "other_module")
|
||||||
|
```
|
||||||
|
|
||||||
|
`/providers/amazon/src/airflow/providers/amazon/__init__.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
__version__ = "9.15.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
`test.py`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from airflow.providers.amazon import __version__ as amazon_provider_version # error: [unresolved-import]
|
||||||
|
from airflow import __version__ as airflow_version
|
||||||
|
|
||||||
|
reveal_type(airflow_version) # revealed: Literal["3.2.0"]
|
||||||
|
```
|
||||||
|
|
@ -19,7 +19,10 @@ use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||||
use ruff_db::files::{File, FilePath, FileRootKind};
|
use ruff_db::files::{File, FilePath, FileRootKind};
|
||||||
use ruff_db::system::{DirectoryEntry, System, SystemPath, SystemPathBuf};
|
use ruff_db::system::{DirectoryEntry, System, SystemPath, SystemPathBuf};
|
||||||
use ruff_db::vendored::VendoredFileSystem;
|
use ruff_db::vendored::VendoredFileSystem;
|
||||||
use ruff_python_ast::{PySourceType, PythonVersion};
|
use ruff_python_ast::{
|
||||||
|
self as ast, PySourceType, PythonVersion,
|
||||||
|
visitor::{Visitor, walk_body},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use crate::module_name::ModuleName;
|
use crate::module_name::ModuleName;
|
||||||
|
|
@ -1002,7 +1005,12 @@ where
|
||||||
let is_regular_package = package_path.is_regular_package(resolver_state);
|
let is_regular_package = package_path.is_regular_package(resolver_state);
|
||||||
|
|
||||||
if is_regular_package {
|
if is_regular_package {
|
||||||
in_namespace_package = false;
|
// This is the only place where we need to consider the existence of legacy namespace
|
||||||
|
// packages, as we are explicitly searching for the *parent* package of the module
|
||||||
|
// we actually want. Here, such a package should be treated as a PEP-420 ("modern")
|
||||||
|
// namespace package. In all other contexts it acts like a normal package and needs
|
||||||
|
// no special handling.
|
||||||
|
in_namespace_package = is_legacy_namespace_package(&package_path, resolver_state);
|
||||||
} else if package_path.is_directory(resolver_state)
|
} else if package_path.is_directory(resolver_state)
|
||||||
// Pure modules hide namespace packages with the same name
|
// Pure modules hide namespace packages with the same name
|
||||||
&& resolve_file_module(&package_path, resolver_state).is_none()
|
&& resolve_file_module(&package_path, resolver_state).is_none()
|
||||||
|
|
@ -1039,6 +1047,62 @@ where
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether a package is a legacy namespace package.
|
||||||
|
///
|
||||||
|
/// Before PEP 420 introduced implicit namespace packages, the ecosystem developed
|
||||||
|
/// its own form of namespace packages. These legacy namespace packages continue to persist
|
||||||
|
/// in modern codebases because they work with ancient Pythons and if it ain't broke, don't fix it.
|
||||||
|
///
|
||||||
|
/// A legacy namespace package is distinguished by having an `__init__.py` that contains an
|
||||||
|
/// expression to the effect of:
|
||||||
|
///
|
||||||
|
/// ```python
|
||||||
|
/// __path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The resulting package simultaneously has properties of both regular packages and namespace ones:
|
||||||
|
///
|
||||||
|
/// * Like regular packages, `__init__.py` is defined and can contain items other than submodules
|
||||||
|
/// * Like implicit namespace packages, multiple copies of the package may exist with different
|
||||||
|
/// submodules, and they will be merged into one namespace at runtime by the interpreter
|
||||||
|
///
|
||||||
|
/// Now, you may rightly wonder: "What if the `__init__.py` files have different contents?"
|
||||||
|
/// The apparent official answer is: "Don't do that!"
|
||||||
|
/// And the reality is: "Of course people do that!"
|
||||||
|
///
|
||||||
|
/// In practice we think it's fine to, just like with regular packages, use the first one
|
||||||
|
/// we find on the search paths. To the extent that the different copies "need" to have the same
|
||||||
|
/// contents, they all "need" to have the legacy namespace idiom (we do nothing to enforce that,
|
||||||
|
/// we will just get confused if you mess it up).
|
||||||
|
fn is_legacy_namespace_package(
|
||||||
|
package_path: &ModulePath,
|
||||||
|
resolver_state: &ResolverContext,
|
||||||
|
) -> bool {
|
||||||
|
// Just an optimization, the stdlib and typeshed are never legacy namespace packages
|
||||||
|
if package_path.search_path().is_standard_library() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut package_path = package_path.clone();
|
||||||
|
package_path.push("__init__");
|
||||||
|
let Some(init) = resolve_file_module(&package_path, resolver_state) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is all syntax-only analysis so it *could* be fooled but it's really unlikely.
|
||||||
|
//
|
||||||
|
// The benefit of being syntax-only is speed and avoiding circular dependencies
|
||||||
|
// between module resolution and semantic analysis.
|
||||||
|
//
|
||||||
|
// The downside is if you write slightly different syntax we will fail to detect the idiom,
|
||||||
|
// but hey, this is better than nothing!
|
||||||
|
let parsed = ruff_db::parsed::parsed_module(resolver_state.db, init);
|
||||||
|
let mut visitor = LegacyNamespacePackageVisitor::default();
|
||||||
|
visitor.visit_body(parsed.load(resolver_state.db).suite());
|
||||||
|
|
||||||
|
visitor.is_legacy_namespace_package
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ResolvedPackage {
|
struct ResolvedPackage {
|
||||||
path: ModulePath,
|
path: ModulePath,
|
||||||
|
|
@ -1148,6 +1212,124 @@ impl fmt::Display for RelaxedModuleName {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detects if a module contains a statement of the form:
|
||||||
|
/// ```python
|
||||||
|
/// __path__ = pkgutil.extend_path(__path__, __name__)
|
||||||
|
/// ```
|
||||||
|
/// or
|
||||||
|
/// ```python
|
||||||
|
/// __path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||||
|
/// ```
|
||||||
|
#[derive(Default)]
|
||||||
|
struct LegacyNamespacePackageVisitor {
|
||||||
|
is_legacy_namespace_package: bool,
|
||||||
|
in_body: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visitor<'_> for LegacyNamespacePackageVisitor {
|
||||||
|
fn visit_body(&mut self, body: &[ruff_python_ast::Stmt]) {
|
||||||
|
if self.is_legacy_namespace_package {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't traverse into nested bodies.
|
||||||
|
if self.in_body {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.in_body = true;
|
||||||
|
|
||||||
|
walk_body(self, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_stmt(&mut self, stmt: &ast::Stmt) {
|
||||||
|
if self.is_legacy_namespace_package {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ast::Stmt::Assign(ast::StmtAssign { value, targets, .. }) = stmt else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let [ast::Expr::Name(maybe_path)] = &**targets else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if &*maybe_path.id != "__path__" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ast::Expr::Call(ast::ExprCall {
|
||||||
|
func: extend_func,
|
||||||
|
arguments: extend_arguments,
|
||||||
|
..
|
||||||
|
}) = &**value
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let ast::Expr::Attribute(ast::ExprAttribute {
|
||||||
|
value: maybe_pkg_util,
|
||||||
|
attr: maybe_extend_path,
|
||||||
|
..
|
||||||
|
}) = &**extend_func
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match if the left side of the attribute access is either `__import__("pkgutil")` or `pkgutil`
|
||||||
|
match &**maybe_pkg_util {
|
||||||
|
// __import__("pkgutil").extend_path(__path__, __name__)
|
||||||
|
ast::Expr::Call(ruff_python_ast::ExprCall {
|
||||||
|
func: maybe_import,
|
||||||
|
arguments: import_arguments,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let ast::Expr::Name(maybe_import) = &**maybe_import else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if maybe_import.id() != "__import__" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ast::Expr::StringLiteral(name)) =
|
||||||
|
import_arguments.find_argument_value("name", 0)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if name.value.to_str() != "pkgutil" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "pkgutil.extend_path(__path__, __name__)"
|
||||||
|
ast::Expr::Name(name) => {
|
||||||
|
if name.id() != "pkgutil" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that this is an `extend_path(__path__, __name__)` call
|
||||||
|
if maybe_extend_path != "extend_path" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ast::Expr::Name(path)) = extend_arguments.find_argument_value("path", 0) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(ast::Expr::Name(name)) = extend_arguments.find_argument_value("name", 1) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.is_legacy_namespace_package = path.id() == "__path__" && name.id() == "__name__";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#![expect(
|
#![expect(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue