Skip to content
Closed
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
95 changes: 95 additions & 0 deletions crates/pyrefly_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,18 @@ pub struct SourceDbQueryOutcome {
pub error: Option<String>,
}

/// The oldest Python version for which the bundled typeshed stubs are accurate.
///
/// Typeshed publishes this as `[tool.typeshed] oldest-supported-python` in its
/// `pyproject.toml`. We mirror it here because Pyrefly only bundles typeshed's `stdlib/`
/// and `stubs/` trees, not its `pyproject.toml`. Update this whenever the vendored
/// typeshed is updated and drops support for an older version.
const TYPESHED_OLDEST_SUPPORTED_PYTHON: PythonVersion = PythonVersion {
major: 3,
minor: 10,
micro: 0,
};

impl ConfigFile {
/// Gets a ConfigFile for a project directory. `fallback` indicates whether this is a guessed
/// project root that we're falling back to after failing to otherwise find an import.
Expand Down Expand Up @@ -2021,6 +2033,21 @@ impl ConfigFile {
}
configure_errors.extend(validate(&self.search_path_from_file, "search-path"));

// Typeshed removes the version-specific branches and guards for Python versions it no
// longer supports, so the bundled stubs only describe the standard library accurately for
// versions >= the oldest supported one. A custom `typeshed-path` replaces the bundled
// stdlib and may describe older versions, so only warn for the bundled typeshed.
// Warn instead of failing, because the user's own code may still legitimately target an
// older version.
if self.typeshed_path.is_none() {
let python_version = self.python_version();
if python_version < TYPESHED_OLDEST_SUPPORTED_PYTHON {
configure_errors.push(anyhow!(
"Python version {python_version} is older than the oldest version described by the bundled typeshed stubs ({TYPESHED_OLDEST_SUPPORTED_PYTHON}). The bundled stubs no longer include the version-specific branches and guards for older versions, so the standard library may be modeled inaccurately for this target. If the project must keep targeting {python_version}, provide compatible custom stubs with `typeshed-path`; otherwise raise `python-version` to {TYPESHED_OLDEST_SUPPORTED_PYTHON} or newer."
));
}
}

if let ConfigSource::File(path) = &self.source {
configure_errors
.into_map(|e| ConfigError::warn(e.context(format!("{}", path.display()))))
Expand Down Expand Up @@ -2744,6 +2771,74 @@ mod tests {
assert!(!settings.contains_key("use-ignore-files"));
}

#[test]
fn test_configure_warns_when_python_version_is_older_than_typeshed() {
let mut config = ConfigFile::default();
config.interpreters.skip_interpreter_query = true;
config.python_environment.python_version = Some(PythonVersion::new(3, 9, 0));

let messages: Vec<String> = config
.configure()
.iter()
.filter(|error| error.severity() == Severity::Warn)
.map(|error| error.get_message())
.collect();

assert!(
messages.iter().any(|message| message
.contains("older than the oldest version described by the bundled typeshed stubs")),
"expected a warning about an outdated Python version, got: {messages:?}"
);
}

#[test]
fn test_configure_does_not_warn_when_custom_typeshed_is_configured() {
let mut config = ConfigFile::default();
config.interpreters.skip_interpreter_query = true;
config.python_environment.python_version = Some(PythonVersion::new(3, 9, 0));
// A custom typeshed replaces the bundled stdlib and may describe older versions.
config.typeshed_path = Some(PathBuf::from("/custom/typeshed"));

let messages: Vec<String> = config
.configure()
.iter()
.map(|error| error.get_message())
.collect();

assert!(
!messages
.iter()
.any(|message| message.contains("bundled typeshed stubs")),
"unexpected bundled-typeshed warning when a custom typeshed is configured: {messages:?}"
);
}

#[test]
fn test_configure_does_not_warn_when_python_version_is_typeshed_supported() {
for version in [
PythonVersion::new(3, 10, 0),
PythonVersion::new(3, 11, 0),
PythonVersion::new(3, 13, 0),
] {
let mut config = ConfigFile::default();
config.interpreters.skip_interpreter_query = true;
config.python_environment.python_version = Some(version);

let messages: Vec<String> = config
.configure()
.iter()
.map(|error| error.get_message())
.collect();

assert!(
!messages.iter().any(|message| message.contains(
"older than the oldest version described by the bundled typeshed stubs"
)),
"unexpected outdated-Python warning for {version}: {messages:?}"
);
}
}

#[test]
fn deserialize_pyrefly_config() {
let config_str = r#"
Expand Down
41 changes: 31 additions & 10 deletions crates/pyrefly_config/src/finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,14 @@ type BeforeCallback =
type LoadCallback = Box<dyn Fn(&Path) -> (ArcId<ConfigFile>, Vec<ConfigError>) + Send + Sync>;

/// Fallback function when no config file exists or loading fails.
type FallbackCallback =
Box<dyn Fn(ModuleNameWithKind, &ModulePath) -> ArcId<ConfigFile> + Send + Sync>;
///
/// Returns the config along with any user-facing warnings produced while configuring it (for
/// example, a warning that the resolved `python-version` predates the bundled typeshed). Those
/// warnings are accumulated so callers surface them like config-file errors; a synthesized config
/// must not silently swallow them.
type FallbackCallback = Box<
dyn Fn(ModuleNameWithKind, &ModulePath) -> (ArcId<ConfigFile>, Vec<ConfigError>) + Send + Sync,
>;

/// Function that replaces the config found for a Python file with one for that file alone, such
/// as a PEP 723 script that runs in its own environment.
Expand Down Expand Up @@ -174,7 +180,7 @@ impl ConfigFinder {
Self::new_custom(
Box::new(move |_, _| Ok(Some(c1.dupe()))),
Box::new(move |_| (c2.dupe(), Vec::new())),
Box::new(move |_, _| c3.dupe()),
Box::new(move |_, _| (c3.dupe(), Vec::new())),
file,
Box::new(|| {}),
)
Expand Down Expand Up @@ -298,15 +304,17 @@ impl ConfigFinder {
}
}

let f = |dir: Option<&Path>| match dir {
Some(parent) => self
.search
.directory_absolute(parent)
.unwrap_or_else(|| (self.fallback)(name, path)),
None => (self.fallback)(name, path),
let f = |dir: Option<&Path>| -> (ArcId<ConfigFile>, Vec<ConfigError>) {
match dir {
Some(parent) => match self.search.directory_absolute(parent) {
Some(config) => (config, Vec::new()),
None => (self.fallback)(name, path),
},
None => (self.fallback)(name, path),
}
};

let config = match path.details() {
let (config, errors) = match path.details() {
ModulePathDetails::FileSystem(x) | ModulePathDetails::Memory(x) => {
let absolute = x.absolutize();
f(absolute.parent())
Expand All @@ -316,6 +324,19 @@ impl ConfigFinder {
ModulePathDetails::BundledTypeshedThirdParty(_) => f(None),
ModulePathDetails::BundledThirdParty(_) => f(None),
};
// One run can synthesize configs for several module paths, and the same user-facing
// warning may be produced for more than one of them; report it once.
{
let mut accumulated = self.errors.lock();
for error in errors {
if !accumulated
.iter()
.any(|existing| existing.get_message() == error.get_message())
{
accumulated.push(error);
}
}
}
(self.file)(config, path)
}

Expand Down
Loading
Loading