Skip to content
Open
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
27 changes: 16 additions & 11 deletions crates/pyrefly_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,21 @@ impl ConfigFile {
}
}

/// The exclude globs the given scope selects, unless overridden by `custom_excludes`.
pub fn excludes(&self, custom_excludes: Option<Globs>, scope: ConfigScope) -> Globs {
let custom_excludes = match scope {
ConfigScope::Default => custom_excludes,
ConfigScope::Coverage => custom_excludes.or_else(|| self.coverage.excludes.clone()),
};
match custom_excludes {
None => self.project_excludes.clone(),
Some(custom_excludes) if !self.disable_project_excludes_heuristics => {
self.get_full_project_excludes(custom_excludes)
}
Some(custom_excludes) => custom_excludes,
}
}

/// Gets a [`FilteredGlobs`] from the optional `custom_excludes` or this
/// [`ConfigFile`]s `project_excludes`, adding all `site_package_path` entries
/// as extra exclude items.
Expand All @@ -910,17 +925,7 @@ impl ConfigFile {
scope: ConfigScope,
) -> FilteredGlobs {
let includes = self.includes(scope).clone();
let custom_excludes = match scope {
ConfigScope::Default => custom_excludes,
ConfigScope::Coverage => custom_excludes.or_else(|| self.coverage.excludes.clone()),
};
let project_excludes = match custom_excludes {
None => self.project_excludes.clone(),
Some(custom_excludes) if !self.disable_project_excludes_heuristics => {
self.get_full_project_excludes(custom_excludes)
}
Some(custom_excludes) => custom_excludes,
};
let project_excludes = self.excludes(custom_excludes, scope);
let root = if self.use_ignore_files {
self.import_root.as_deref()
} else {
Expand Down
51 changes: 36 additions & 15 deletions pyrefly/lib/commands/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::path::PathBuf;
use anyhow::Context as _;
use clap::Parser;
use dupe::Dupe;
use itertools::Itertools;
use pyrefly_config::args::ConfigOverrideArgs;
use pyrefly_config::resolve_unconfigured::UnconfiguredOverride;
use pyrefly_util::absolutize::Absolutize as _;
Expand Down Expand Up @@ -64,17 +65,19 @@ pub struct FilesArgs {
/// Files to check (glob supported).
/// If no file is specified, switch to project-checking mode where the files to
/// check are determined from the closest configuration file.
/// When supplied, `project_excludes` in any config files loaded for these files to check
/// are ignored, and we use the default excludes unless overridden with the `--project-excludes` flag.
/// When supplied, the `project_excludes` of the config file found for the base directory of
/// each pattern are used if that is the same config file for every pattern, ignoring any
/// configs nested below a base directory. Otherwise, we use the default excludes. In both
/// cases, the `--project-excludes` flag overrides them.
files: Vec<String>,
/// Files to exclude when type checking.
#[arg(long)]
project_excludes: Option<Vec<String>>,

/// Explicitly set the Pyrefly configuration to use when type checking.
/// In "single-file checking mode," this config is applied to all files being checked, ignoring
/// the config's `project_includes` and `project_excludes` and ignoring any config-finding approach
/// that would otherwise be used.
/// the config's `project_includes` and ignoring any config-finding approach that would
/// otherwise be used.
/// When not set, Pyrefly will perform an upward-filesystem-walk approach to find the nearest
/// pyrefly.toml or pyproject.toml with `tool.pyrefly` section'. If no config is found, Pyrefly exits with error.
/// If both a pyrefly.toml and valid pyproject.toml are found, pyrefly.toml takes precedence.
Expand Down Expand Up @@ -259,10 +262,23 @@ fn get_globs_and_config_for_files(
project_excludes: Option<Globs>,
args: ConfigOverrideArgs,
wrapper: Option<ConfigConfigurerWrapper>,
scope: ConfigScope,
) -> anyhow::Result<(Box<dyn Includes>, ConfigFinder, UpsellDecision)> {
let files_to_check = absolutize(files_to_check);
let args_disable_excludes_heuristics = args.disable_project_excludes_heuristics();
let has_cli_excludes = project_excludes.is_some();
let get_project_excludes_and_heuristics = move |config: Option<&ConfigFile>| {
// Unless overridden by `--project-excludes`, a config file's excludes and heuristics apply
// like in project mode. Synthesized configs fall back to the default excludes below.
if !has_cli_excludes
&& let Some(config) = config.filter(|config| config.from_real_config_file())
{
return (
config.excludes(None, scope),
!config.disable_project_excludes_heuristics,
);
}

let mut project_excludes = project_excludes.unwrap_or_default();
let disable = args_disable_excludes_heuristics
.or_else(|| Some(config?.disable_project_excludes_heuristics))
Expand All @@ -288,18 +304,23 @@ fn get_globs_and_config_for_files(
}
None => {
let config_finder = default_config_finder_with_overrides(args, false, wrapper);
// The config excludes are unambiguous iff. every root corresponds to the same config.
let shared_config = files_to_check
.roots()
.iter()
.map(|root| config_finder.directory(root))
.all_equal_value()
.ok()
.flatten();
// With `--project-excludes` the shared config is ignored.
let (project_excludes, use_heuristics) = get_project_excludes_and_heuristics(
shared_config.as_deref().filter(|_| !has_cli_excludes),
);

// If there is only one input and one root, we treat config parse errors as fatal,
// so that `pyrefly check .` exits immediately on an unparsable config, matching the
// behavior of `pyrefly check` (see get_globs_and_config_for_project).
let solo_root = if files_to_check.len() == 1 {
files_to_check.roots().first().cloned()
} else {
None
};
let (project_excludes, use_heuristics) = get_project_excludes_and_heuristics(None);
let (config_finder, errors) = if let Some(root) = solo_root {
// We don't care about the contents of the config, only if we generated any errors while parsing it.
config_finder.directory(&root);
let (config_finder, errors) = if files_to_check.len() == 1 {
let errors = config_finder.errors();
(config_finder, errors)
} else {
Expand Down Expand Up @@ -343,7 +364,7 @@ impl FilesArgs {
self.resolve_scoped(config_override, wrapper, ConfigScope::Default)
}

/// [`FilesArgs::resolve`], reading the project-mode globs from `scope`.
/// [`FilesArgs::resolve`], reading the config globs from `scope`.
pub fn resolve_scoped(
self,
config_override: ConfigOverrideArgs,
Expand All @@ -364,13 +385,13 @@ impl FilesArgs {
scope,
)
} else {
// File mode bypasses the config's globs, so `scope` is intentionally unused.
get_globs_and_config_for_files(
self.config,
Globs::new(self.files)?,
project_excludes,
config_override,
wrapper,
scope,
)
}
}
Expand Down
27 changes: 27 additions & 0 deletions test/config_discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,30 @@ Configuration at `*/hidden_config/.pyrefly.toml` (glob)
* (glob+)
[0]
```

## File mode applies `project-excludes` if every pattern finds the same config

```scrut {output_stream: stdout}
$ mkdir -p $TMPDIR/file_mode_excludes/tests && cd $TMPDIR/file_mode_excludes && \
> echo 'project-excludes = ["tests/**"]' > pyrefly.toml && \
> echo "x: str = 1" > a.py && \
> echo "x: str = 1" > tests/test_a.py && \
> $PYREFLY check --output-format=min-text a.py tests
ERROR a.py:1:10-11: `Literal[1]` is not assignable to `str` [bad-assignment]
[1]
```

## File mode ignores `project-excludes` if the patterns find different configs

<!-- Reusing file_mode_excludes dir set up in "File mode applies `project-excludes` if every pattern finds the same config" -->

```scrut {output_stream: stdout}
$ mkdir $TMPDIR/file_mode_excludes/other && cd $TMPDIR/file_mode_excludes && \
> touch other/pyrefly.toml && \
> echo "x: str = 1" > other/b.py && \
> $PYREFLY check --output-format=min-text a.py tests other
ERROR a.py:1:10-11: `Literal[1]` is not assignable to `str` [bad-assignment]
ERROR other/b.py:1:10-11: `Literal[1]` is not assignable to `str` [bad-assignment]
ERROR tests/test_a.py:1:10-11: `Literal[1]` is not assignable to `str` [bad-assignment]
[1]
```
12 changes: 12 additions & 0 deletions test/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ $ touch $TMPDIR/pyrefly.toml && \
> set -o pipefail && $PYREFLY report $TMPDIR/large.py 2>/dev/null | dd bs=1 count=0 2>/dev/null
[0]
```

## Coverage commands apply `[coverage] excludes` in file mode

```scrut {output_stream: stderr}
$ mkdir $TMPDIR/covexcl && cd $TMPDIR/covexcl && \
> echo 'coverage.excludes = ["untyped.py"]' > pyrefly.toml && \
> echo "x: int = 1" > typed.py && \
> echo "def f(x): return x" > untyped.py && \
> $PYREFLY coverage check .
INFO type coverage 100.00% (1 of 1 typable)
[0]
```
30 changes: 17 additions & 13 deletions website/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,13 @@ correspond to different but useful ways we expect most people to interact with P
[`project-includes`](#project-includes) and [`project-excludes`](#project-excludes) from the file, expanding the patterns,
and type checking on those files.
- Project mode is used whenever no files are provided with the CLI invocation.
- **Per-file** or **Single-file** mode: when given [`FILES...`](#project-includes) (and optionally [`--project-excludes`](#project-excludes))
during a CLI invocation, expand the patterns and find the relevant config file for each
file listed. `project-includes` and `project-excludes` are ignored from the config file,
but it is used for all remaining config options.
- **Per-file** or **Single-file** mode: when given [`FILES...`](#project-includes) during a CLI
invocation, expand the patterns and find the relevant config file for each file listed. That
config file is used for all options except `project-includes`, which is overridden by `FILES...`.
Its `project-excludes` are only used if each file pattern finds the same config file from its
base directory (config files nested below it are not considered), or if one is provided with
`-c`/`--config`. Otherwise, the default excludes are used.
In either case, [`--project-excludes`](#project-excludes) takes precedence.

## Configuration Finding

Expand Down Expand Up @@ -158,8 +161,8 @@ If a `pyproject.toml` with `[tool.pyrefly]` is found, Pyrefly parses and uses th
### Providing a Config in Single-File Mode

Providing `-c`/`--config` in single-file checking mode disables the upward file search for config
files. All options are read from the provided config file except `project-includes` and
`project-excludes`, which are ignored.
files. All options are read from the provided config file except `project-includes`, which is
ignored.

## Configuration Options

Expand All @@ -181,9 +184,9 @@ import should be resolved from. See [`search-path`](#search-path) instead.
- Equivalent configs: `include` in Pyright, `files`/`modules`/`packages` in
mypy
- Notes:
- When overridden by passing in `FILES...`, we do not consult the
relevant config file for what to use for `project-excludes`.
If `project-excludes` should not use the default value, override it with the flag as
- When overridden by passing in `FILES...`, we only consult the relevant config file for
what to use for `project-excludes` if every pattern finds the same config file (or one is
provided with `-c`/`--config`). To use other excludes, override them with the flag as
well. This is because
if multiple configs are loaded that conflict with `project-includes`, determining
how to resolve checkable files gets complicated, and might become confusing to
Expand Down Expand Up @@ -223,9 +226,9 @@ or remove `project-excludes`.
they are filtered out by `project-excludes` entries. We differentiate between
an error from a `project-includes` that doesn't match any files, and an error
from all `project-includes` getting filtered by `project-excludes`.
- When overridden by passing in `FILES...`, we do not consult the
relevant config file for what to use for `project-excludes`.
If `project-excludes` should not use the default value, override it with the flag as
- When overridden by passing in `FILES...`, we only consult the relevant config file for
what to use for `project-excludes` if every pattern finds the same config file (or one is
provided with `-c`/`--config`). To use other excludes, override them with the flag as
well. See reasoning in [`project-includes` notes](#project-includes).
- Your [`site-packages-path`](#site-packages-path) is added to your `project-excludes`
automatically. If you are trying to perform type checking on a dependency in your
Expand Down Expand Up @@ -264,7 +267,8 @@ project setting when unset.
- Default: `project-includes` / `project-excludes`
- Flag equivalent: none, though `--project-excludes` takes precedence over `coverage.excludes`
- Notes:
- Like the project globs, these are not consulted when passing in `FILES...`.
- When passing in `FILES...`, `coverage.includes` is ignored, and `coverage.excludes` is
used under the same conditions as [`project-excludes`](#project-excludes).
- `coverage.excludes` gets the same default excludes appended as `project-excludes` does,
unless [`disable-project-excludes-heuristics`](#disable-project-excludes-heuristics) is set.

Expand Down
Loading