Compare commits

...

15 Commits

Author SHA1 Message Date
Charlie Marsh
2f3a950f6f Bump version to 0.0.287 (#7038) 2023-09-01 17:32:26 +01:00
Charlie Marsh
dea65536e9 Fix placement for comments within f-strings concatenations (#7047)
## Summary

Restores the dangling comment handling for f-strings, which broke with
the parenthesized expression code.

Closes https://github.com/astral-sh/ruff/issues/6898.

## Test Plan

`cargo test`

No change in any of the similarity indexes or changed file counts:

| project | similarity index | total files | changed files |

|--------------|------------------:|------------------:|------------------:|
| cpython | 0.76083 | 1789 | 1632 |
| django | 0.99957 | 2760 | 67 |
| transformers | 0.99927 | 2587 | 468 |
| twine | 0.99982 | 33 | 1 |
| typeshed | 0.99978 | 3496 | 2173 |
| warehouse | 0.99818 | 648 | 24 |
| zulip | 0.99942 | 1437 | 32 |
2023-09-01 16:27:32 +00:00
dependabot[bot]
fbc9b5a604 Bump cloudflare/wrangler-action from 3.1.0 to 3.1.1 (#7045) 2023-09-01 15:00:11 +00:00
Zanie Blue
253a241f5d Add dependabot for cargo dependencies (#7034)
Ideally we shouldn't have to run `cargo update` manually — it requires
us to remember to do so and groups all updates into a single pull
request making it challenging to determine which upgrade introduces
regressions e.g. #6964. Here we add daily checks for cargo dependency
updates.

This pull request also simplifies dependabot configuration for GitHub
Actions versions.
2023-09-01 09:47:40 -05:00
Sergey Chudov
33806b8b7c Fixed panic in missing_copyright_notice (#7029) 2023-09-01 13:58:48 +00:00
Charlie Marsh
afcd00da56 Create ruff_notebook crate (#7039)
## Summary

This PR moves `ruff/jupyter` into its own `ruff_notebook` crate. Beyond
the move itself, there were a few challenges:

1. `ruff_notebook` relies on the source map abstraction. I've moved the
source map into `ruff_diagnostics`, since it doesn't have any
dependencies on its own and is used alongside diagnostics.
2. `ruff_notebook` has a couple tests for end-to-end linting and
autofixing. I had to leave these tests in `ruff` itself.
3. We had code in `ruff/jupyter` that relied on Python lexing, in order
to provide a more targeted error message in the event that a user saves
a `.py` file with a `.ipynb` extension. I removed this in order to avoid
a dependency on the parser, it felt like it wasn't worth retaining just
for that dependency.

## Test Plan

`cargo test`
2023-09-01 13:56:44 +00:00
Charlie Marsh
08e246764f Refactor ruff_cli's run method to return on each branch (#7040)
## Summary

I think the fallthrough here for some branches is a little confusing.
Now each branch either runs a command that returns `Result<ExitStatus>`,
or runs a command that returns `Result<()>` and then explicitly returns
`Ok(ExitStatus::SUCCESS)`.
2023-09-01 14:15:38 +01:00
Chris Pryer
0489bbc54c Match Black's formatting of trailing comments containing NBSP (#7030) 2023-09-01 14:52:59 +02:00
Charlie Marsh
60132da7bb Add a NotebookError type to avoid returning Diagnostics on error (#7035)
## Summary

This PR refactors the error-handling cases around Jupyter notebooks to
use errors rather than `Box<Diagnostics>`, which creates some oddities
in the downstream handling. So, instead of formatting errors as
diagnostics _eagerly_ (in the notebook methods), we now return errors
and convert those errors to diagnostics at the last possible moment (in
`diagnostics.rs`). This is more ergonomic, as errors can be composed and
reported-on in different ways, whereas diagnostics require a `Printer`,
etc.

See, e.g.,
https://github.com/astral-sh/ruff/pull/7013#discussion_r1311136301.

## Test Plan

Ran `cargo run` over a Python file labeled with a `.ipynb` suffix, and
saw:

```
foo.ipynb:1:1: E999 SyntaxError: Expected a Jupyter Notebook, which must be internally stored as JSON, but found a Python source file: expected value at line 1 column 1
```
2023-09-01 11:08:05 +00:00
Chris Pryer
17a44c0078 Exclude pragma comments from measured line width (#7008)
Co-authored-by: Micha Reiser <micha@reiser.io>
2023-09-01 06:34:51 +00:00
Charlie Marsh
376d3caf47 Treat empty-line separated comments as trailing statement comments (#6999)
## Summary

This PR modifies our between-statement comment handling such that
comments that are not separated by a statement by any newlines continue
to be treated as leading comments on the statement, but comments that
_are_ separated are instead formatted as trailing comments on the
preceding statement.

See, e.g., the originating snippet:

```python
DEFAULT_TEMPLATE = "flatpages/default.html"

# This view is called from FlatpageFallbackMiddleware.process_response
# when a 404 is raised, which often means CsrfViewMiddleware.process_view
# has not been called even if CsrfViewMiddleware is installed. So we need
# to use @csrf_protect, in case the template needs {% csrf_token %}.
# However, we can't just wrap this view; if no matching flatpage exists,
# or a redirect is required for authentication, the 404 needs to be returned
# without any CSRF checks. Therefore, we only
# CSRF protect the internal implementation.


def flatpage(request, url):
    pass
```

Here, we need to ensure that the `def flatpage` is precede by two empty
lines. However, we want those two empty lines to be enforced from the
_end_ of the comment block, _unless_ the comments are directly atop the
`def flatpage`.

I played with this a bit, and I think the simplest conceptual model and
implementation is to instead treat those as trailing comments on the
preceding node. The main difficulty with this approach is that, in order
to be fully compatible with Black, we'd sometimes need to insert
newlines _between_ the preceding node and its trailing comments. See,
e.g.:

```python
def func():
    ...
# comment

x = 1
```

In this case, we'd need to insert two blank lines between `def func():
...` and `# comment`, but `# comment` is trailing comment on `def
func(): ...`. So, we'd need to take this case into account in the
various nodes that _require_ newlines after them: functions, classes,
and imports. After some discussion, we've opted _not_ to support this,
and just treat these as trailing comments -- so we won't insert newlines
there. This means our handling is still identical to Black's on
Black-formatted code, but avoids moving such trailing comments on
unformatted code.

I dislike that the empty handling is so complex, and that it's split
between so many different nodes, but this is really tricky. Continuing
to treat these as leading comments is very difficult too, since we'd
need to do similar tricks for the leading comment handling in those
nodes, and influencing leading comments is even harder, since they're
all formatted _before_ the node itself.

Closes https://github.com/astral-sh/ruff/issues/6761.

## Test Plan

`cargo test`

Surprisingly, it doesn't change the similarity at all (apart from a
0.00001 change in CPython), but I manually confirmed that it did fix the
originating issue in Django.

Before:

| project      | similarity index |
|--------------|------------------|
| cpython      | 0.76082          |
| django       | 0.99921          |
| transformers | 0.99854          |
| twine        | 0.99982          |
| typeshed     | 0.99953          |
| warehouse    | 0.99648          |
| zulip        | 0.99928          |


After:

| project      | similarity index |
|--------------|------------------|
| cpython      | 0.76081          |
| django       | 0.99921          |
| transformers | 0.99854          |
| twine        | 0.99982          |
| typeshed     | 0.99953          |
| warehouse    | 0.99648          |
| zulip        | 0.99928          |
2023-08-31 20:55:05 +00:00
Charlie Marsh
51d69b448c Improve compatibility between multi-statement PYI rules (#7024)
## Summary

This PR modifies a few of our rules related to which statements (and how
many) are allowed in function bodies within `.pyi` files, to improve
compatibility with flake8-pyi and improve the interplay dynamics between
them. Each change fixes a deviation from flake8-pyi:

- We now always trigger the multi-statement rule (PYI048) regardless of
whether one of the statements is a docstring.
- We no longer trigger the `...` rule (PYI010) if the single statement
is a docstring or a `pass` (since those are covered by other rules).
- We no longer trigger the `...` rule (PYI010) if the function body
contains multiple statements (since that's covered by PYI048).

Closes https://github.com/astral-sh/ruff/issues/7021.

## Test Plan

`cargo test`
2023-08-31 21:45:26 +01:00
Nicholas Grisafi
f7dca3d958 Add Rippling to who uses ruff (#7032)
## Summary

Adding rippling to who uses ruff section

## Test Plan

We migrated to ruff 🙂
2023-08-31 18:36:48 +00:00
Charlie Marsh
7c1aa98f43 Run cargo update (#6964) 2023-08-31 13:11:11 -05:00
Charlie Marsh
68f605e80a Fix WithItem ranges for parenthesized, non-as items (#6782)
## Summary

This PR attempts to address a problem in the parser related to the
range's of `WithItem` nodes in certain contexts -- specifically,
`WithItem` nodes in parentheses that do not have an `as` token after
them.

For example,
[here](https://play.ruff.rs/71be2d0b-2a04-4c7e-9082-e72bff152679):

```python
with (a, b):
    pass
```

The range of the `WithItem` `a` is set to the range of `(a, b)`, as is
the range of the `WithItem` `b`. In other words, when we have this kind
of sequence, we use the range of the entire parenthesized context,
rather than the ranges of the items themselves.

Note that this also applies to cases
[like](https://play.ruff.rs/c551e8e9-c3db-4b74-8cc6-7c4e3bf3713a):

```python
with (a, b, c as d):
    pass
```

You can see the issue in the parser here:

```rust
#[inline]
WithItemsNoAs: Vec<ast::WithItem> = {
    <location:@L> <all:OneOrMore<Test<"all">>> <end_location:@R> => {
        all.into_iter().map(|context_expr| ast::WithItem { context_expr, optional_vars: None, range: (location..end_location).into() }).collect()
    },
}
```

Fixing this issue is... very tricky. The naive approach is to use the
range of the `context_expr` as the range for the `WithItem`, but that
range will be incorrect when the `context_expr` is itself parenthesized.
For example, _that_ solution would fail here, since the range of the
first `WithItem` would be that of `a`, rather than `(a)`:

```python
with ((a), b):
    pass
```

The `with` parsing in general is highly precarious due to ambiguities in
the grammar. Changing it in _any_ way seems to lead to an ambiguous
grammar that LALRPOP fails to translate. Consensus seems to be that we
don't really understand _why_ the current grammar works (i.e., _how_ it
avoids these ambiguities as-is).

The solution implemented here is to avoid changing the grammar itself,
and instead change the shape of the nodes returned by various rules in
the grammar. Specifically, everywhere that we return `Expr`, we instead
return `ParenthesizedExpr`, which includes a parenthesized range and the
underlying `Expr` itself. (If an `Expr` isn't parenthesized, the ranges
will be equivalent.) In `WithItemsNoAs`, we can then use the
parenthesized range as the range for the `WithItem`.
2023-08-31 16:21:29 +01:00
107 changed files with 14016 additions and 12770 deletions

View File

@@ -4,8 +4,10 @@ updates:
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "12:00"
timezone: "America/New_York"
commit-message:
prefix: "ci(deps)"
labels: ["internal"]
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
labels: ["internal"]

View File

@@ -40,7 +40,7 @@ jobs:
run: mkdocs build --strict -f mkdocs.generated.yml
- name: "Deploy to Cloudflare Pages"
if: ${{ env.CF_API_TOKEN_EXISTS == 'true' }}
uses: cloudflare/wrangler-action@v3.1.0
uses: cloudflare/wrangler-action@v3.1.1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}

View File

@@ -40,7 +40,7 @@ jobs:
working-directory: playground
- name: "Deploy to Cloudflare Pages"
if: ${{ env.CF_API_TOKEN_EXISTS == 'true' }}
uses: cloudflare/wrangler-action@v3.1.0
uses: cloudflare/wrangler-action@v3.1.1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}

View File

@@ -129,6 +129,7 @@ At time of writing, the repository includes the following crates:
intermediate representation. The backend for `ruff_python_formatter`.
- `crates/ruff_index`: library crate inspired by `rustc_index`.
- `crates/ruff_macros`: proc macro crate containing macros used by Ruff.
- `crates/ruff_notebook`: library crate for parsing and manipulating Jupyter notebooks.
- `crates/ruff_python_ast`: library crate containing Python-specific AST types and utilities.
- `crates/ruff_python_codegen`: library crate containing utilities for generating Python source code.
- `crates/ruff_python_formatter`: library crate implementing the Python formatter. Emits an

548
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -140,7 +140,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com) hook:
```yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.0.286
rev: v0.0.287
hooks:
- id: ruff
```
@@ -398,6 +398,7 @@ Ruff is used by a number of major open-source projects and companies, including:
- [Pydantic](https://github.com/pydantic/pydantic)
- [Pylint](https://github.com/PyCQA/pylint)
- [Reflex](https://github.com/reflex-dev/reflex)
- [Rippling](https://rippling.com)
- [Robyn](https://github.com/sansyrox/robyn)
- Scale AI ([Launch SDK](https://github.com/scaleapi/launch-python-client))
- Snowflake ([SnowCLI](https://github.com/Snowflake-Labs/snowcli))

View File

@@ -1,6 +1,6 @@
[package]
name = "flake8-to-ruff"
version = "0.0.286"
version = "0.0.287"
description = """
Convert Flake8 configuration files to Ruff configuration files.
"""

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff"
version = "0.0.286"
version = "0.0.287"
publish = false
authors = { workspace = true }
edition = { workspace = true }
@@ -18,6 +18,7 @@ name = "ruff"
ruff_cache = { path = "../ruff_cache" }
ruff_diagnostics = { path = "../ruff_diagnostics", features = ["serde"] }
ruff_index = { path = "../ruff_index" }
ruff_notebook = { path = "../ruff_notebook" }
ruff_macros = { path = "../ruff_macros" }
ruff_python_ast = { path = "../ruff_python_ast", features = ["serde"] }
ruff_python_codegen = { path = "../ruff_python_codegen" }
@@ -64,17 +65,15 @@ schemars = { workspace = true, optional = true }
semver = { version = "1.0.16" }
serde = { workspace = true }
serde_json = { workspace = true }
serde_with = { version = "3.0.0" }
similar = { workspace = true }
smallvec = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
thiserror = { version = "1.0.43" }
thiserror = { workspace = true }
toml = { workspace = true }
typed-arena = { version = "2.0.2" }
unicode-width = { workspace = true }
unicode_names2 = { version = "0.6.0", git = "https://github.com/youknowone/unicode_names2.git", rev = "4ce16aa85cbcdd9cc830410f1a72ef9a235f2fde" }
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics", "js"] }
wsl = { version = "0.1.0" }
[dev-dependencies]

View File

@@ -3,16 +3,20 @@ def bar():
def foo():
"""foo""" # OK
"""foo""" # OK, docstrings are handled by another rule
def buzz():
print("buzz") # OK, not in stub file
print("buzz") # ERROR PYI010
def foo2():
123 # OK, not in a stub file
123 # ERROR PYI010
def bizz():
x = 123 # OK, not in a stub file
x = 123 # ERROR PYI010
def foo3():
pass # OK, pass is handled by another rule

View File

@@ -1,6 +1,6 @@
def bar(): ... # OK
def foo():
"""foo""" # OK, strings are handled by another rule
"""foo""" # OK, docstrings are handled by another rule
def buzz():
print("buzz") # ERROR PYI010
@@ -10,3 +10,6 @@ def foo2():
def bizz():
x = 123 # ERROR PYI010
def foo3():
pass # OK, pass is handled by another rule

View File

@@ -1,19 +1,27 @@
def bar(): # OK
...
def bar():
... # OK
def oof(): # OK, docstrings are handled by another rule
def bar():
pass # OK
def bar():
"""oof""" # OK
def oof(): # ERROR PYI048
"""oof"""
print("foo")
def foo(): # Ok not in Stub file
def foo(): # ERROR PYI048
"""foo"""
print("foo")
print("foo")
def buzz(): # Ok not in Stub file
def buzz(): # ERROR PYI048
print("fizz")
print("buzz")
print("test")

View File

@@ -1,20 +1,20 @@
def bar(): ... # OK
def bar():
... # OK
pass # OK
def bar():
"""oof""" # OK
def oof(): # OK, docstrings are handled by another rule
"""oof"""
print("foo")
def oof(): # ERROR PYI048
"""oof"""
print("foo")
def foo(): # ERROR PYI048
def foo(): # ERROR PYI048
"""foo"""
print("foo")
print("foo")
def buzz(): # ERROR PYI048
def buzz(): # ERROR PYI048
print("fizz")
print("buzz")
print("test")

View File

@@ -4,17 +4,15 @@ use std::collections::BTreeSet;
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use rustc_hash::{FxHashMap, FxHashSet};
use ruff_diagnostics::{Diagnostic, Edit, Fix, IsolationLevel};
use ruff_diagnostics::{Diagnostic, Edit, Fix, IsolationLevel, SourceMap};
use ruff_source_file::Locator;
use crate::autofix::source_map::SourceMap;
use crate::linter::FixTable;
use crate::registry::{AsRule, Rule};
pub(crate) mod codemods;
pub(crate) mod edits;
pub(crate) mod snippet;
pub(crate) mod source_map;
pub(crate) struct FixResult {
/// The resulting source code, after applying all fixes.
@@ -140,10 +138,9 @@ fn cmp_fix(rule1: Rule, rule2: Rule, fix1: &Fix, fix2: &Fix) -> std::cmp::Orderi
mod tests {
use ruff_text_size::{Ranged, TextSize};
use ruff_diagnostics::{Diagnostic, Edit, Fix};
use ruff_diagnostics::{Diagnostic, Edit, Fix, SourceMarker};
use ruff_source_file::Locator;
use crate::autofix::source_map::SourceMarker;
use crate::autofix::{apply_fixes, FixResult};
use crate::rules::pycodestyle::rules::MissingNewlineAtEndOfFile;
@@ -207,14 +204,8 @@ print("hello world")
assert_eq!(
source_map.markers(),
&[
SourceMarker {
source: 10.into(),
dest: 10.into(),
},
SourceMarker {
source: 10.into(),
dest: 21.into(),
},
SourceMarker::new(10.into(), 10.into(),),
SourceMarker::new(10.into(), 21.into(),),
]
);
}
@@ -250,14 +241,8 @@ class A(Bar):
assert_eq!(
source_map.markers(),
&[
SourceMarker {
source: 8.into(),
dest: 8.into(),
},
SourceMarker {
source: 14.into(),
dest: 11.into(),
},
SourceMarker::new(8.into(), 8.into(),),
SourceMarker::new(14.into(), 11.into(),),
]
);
}
@@ -289,14 +274,8 @@ class A:
assert_eq!(
source_map.markers(),
&[
SourceMarker {
source: 7.into(),
dest: 7.into()
},
SourceMarker {
source: 15.into(),
dest: 7.into()
}
SourceMarker::new(7.into(), 7.into()),
SourceMarker::new(15.into(), 7.into()),
]
);
}
@@ -332,22 +311,10 @@ class A(object):
assert_eq!(
source_map.markers(),
&[
SourceMarker {
source: 8.into(),
dest: 8.into()
},
SourceMarker {
source: 16.into(),
dest: 8.into()
},
SourceMarker {
source: 22.into(),
dest: 14.into(),
},
SourceMarker {
source: 30.into(),
dest: 14.into(),
}
SourceMarker::new(8.into(), 8.into()),
SourceMarker::new(16.into(), 8.into()),
SourceMarker::new(22.into(), 14.into(),),
SourceMarker::new(30.into(), 14.into(),),
]
);
}
@@ -382,14 +349,8 @@ class A:
assert_eq!(
source_map.markers(),
&[
SourceMarker {
source: 7.into(),
dest: 7.into(),
},
SourceMarker {
source: 15.into(),
dest: 7.into(),
}
SourceMarker::new(7.into(), 7.into(),),
SourceMarker::new(15.into(), 7.into(),),
]
);
}

View File

@@ -6,7 +6,7 @@
//! [Ruff]: https://github.com/astral-sh/ruff
pub use rule_selector::RuleSelector;
pub use rules::pycodestyle::rules::IOError;
pub use rules::pycodestyle::rules::{IOError, SyntaxError};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -20,7 +20,6 @@ mod doc_lines;
mod docstrings;
pub mod fs;
mod importer;
pub mod jupyter;
mod lex;
pub mod line_width;
pub mod linter;

View File

@@ -6,8 +6,6 @@ use anyhow::{anyhow, Result};
use colored::Colorize;
use itertools::Itertools;
use log::error;
use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::{AsMode, ParseError};
use rustc_hash::FxHashMap;
use ruff_diagnostics::Diagnostic;
@@ -15,7 +13,8 @@ use ruff_python_ast::imports::ImportMap;
use ruff_python_ast::PySourceType;
use ruff_python_codegen::Stylist;
use ruff_python_index::Indexer;
use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::{AsMode, ParseError};
use ruff_source_file::{Locator, SourceFileBuilder};
use ruff_text_size::Ranged;
@@ -609,3 +608,133 @@ This indicates a bug in `{}`. If you could open an issue at:
);
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use anyhow::Result;
use test_case::test_case;
use ruff_notebook::{Notebook, NotebookError};
use crate::registry::Rule;
use crate::source_kind::SourceKind;
use crate::test::{test_contents, test_notebook_path, TestedNotebook};
use crate::{assert_messages, settings};
/// Construct a path to a Jupyter notebook in the `resources/test/fixtures/jupyter` directory.
fn notebook_path(path: impl AsRef<Path>) -> std::path::PathBuf {
Path::new("../ruff_notebook/resources/test/fixtures/jupyter").join(path)
}
#[test]
fn test_import_sorting() -> Result<(), NotebookError> {
let actual = notebook_path("isort.ipynb");
let expected = notebook_path("isort_expected.ipynb");
let TestedNotebook {
messages,
source_notebook,
..
} = test_notebook_path(
&actual,
expected,
&settings::Settings::for_rule(Rule::UnsortedImports),
)?;
assert_messages!(messages, actual, source_notebook);
Ok(())
}
#[test]
fn test_ipy_escape_command() -> Result<(), NotebookError> {
let actual = notebook_path("ipy_escape_command.ipynb");
let expected = notebook_path("ipy_escape_command_expected.ipynb");
let TestedNotebook {
messages,
source_notebook,
..
} = test_notebook_path(
&actual,
expected,
&settings::Settings::for_rule(Rule::UnusedImport),
)?;
assert_messages!(messages, actual, source_notebook);
Ok(())
}
#[test]
fn test_unused_variable() -> Result<(), NotebookError> {
let actual = notebook_path("unused_variable.ipynb");
let expected = notebook_path("unused_variable_expected.ipynb");
let TestedNotebook {
messages,
source_notebook,
..
} = test_notebook_path(
&actual,
expected,
&settings::Settings::for_rule(Rule::UnusedVariable),
)?;
assert_messages!(messages, actual, source_notebook);
Ok(())
}
#[test]
fn test_json_consistency() -> Result<()> {
let actual_path = notebook_path("before_fix.ipynb");
let expected_path = notebook_path("after_fix.ipynb");
let TestedNotebook {
linted_notebook: fixed_notebook,
..
} = test_notebook_path(
actual_path,
&expected_path,
&settings::Settings::for_rule(Rule::UnusedImport),
)?;
let mut writer = Vec::new();
fixed_notebook.write(&mut writer)?;
let actual = String::from_utf8(writer)?;
let expected = std::fs::read_to_string(expected_path)?;
assert_eq!(actual, expected);
Ok(())
}
#[test_case(Path::new("before_fix.ipynb"), true; "trailing_newline")]
#[test_case(Path::new("no_trailing_newline.ipynb"), false; "no_trailing_newline")]
fn test_trailing_newline(path: &Path, trailing_newline: bool) -> Result<()> {
let notebook = Notebook::from_path(&notebook_path(path))?;
assert_eq!(notebook.trailing_newline(), trailing_newline);
let mut writer = Vec::new();
notebook.write(&mut writer)?;
let string = String::from_utf8(writer)?;
assert_eq!(string.ends_with('\n'), trailing_newline);
Ok(())
}
// Version <4.5, don't emit cell ids
#[test_case(Path::new("no_cell_id.ipynb"), false; "no_cell_id")]
// Version 4.5, cell ids are missing and need to be added
#[test_case(Path::new("add_missing_cell_id.ipynb"), true; "add_missing_cell_id")]
fn test_cell_id(path: &Path, has_id: bool) -> Result<()> {
let source_notebook = Notebook::from_path(&notebook_path(path))?;
let source_kind = SourceKind::IpyNotebook(source_notebook);
let (_, transformed) = test_contents(
&source_kind,
path,
&settings::Settings::for_rule(Rule::UnusedImport),
);
let linted_notebook = transformed.into_owned().expect_ipy_notebook();
let mut writer = Vec::new();
linted_notebook.write(&mut writer)?;
let actual = String::from_utf8(writer)?;
if has_id {
assert!(actual.contains(r#""id": ""#));
} else {
assert!(!actual.contains(r#""id":"#));
}
Ok(())
}
}

View File

@@ -12,8 +12,8 @@ use ruff_python_parser::{ParseError, ParseErrorType};
use ruff_source_file::{OneIndexed, SourceCode, SourceLocation};
use crate::fs;
use crate::jupyter::Notebook;
use crate::source_kind::SourceKind;
use ruff_notebook::Notebook;
pub static WARNINGS: Lazy<Mutex<Vec<&'static str>>> = Lazy::new(Mutex::default);

View File

@@ -4,10 +4,10 @@ use std::num::NonZeroUsize;
use colored::Colorize;
use ruff_notebook::{Notebook, NotebookIndex};
use ruff_source_file::OneIndexed;
use crate::fs::relativize_path;
use crate::jupyter::{Notebook, NotebookIndex};
use crate::message::diff::calculate_print_width;
use crate::message::text::{MessageCodeFrame, RuleCodeAndBody};
use crate::message::{

View File

@@ -14,12 +14,11 @@ pub use json_lines::JsonLinesEmitter;
pub use junit::JunitEmitter;
pub use pylint::PylintEmitter;
use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
use ruff_notebook::Notebook;
use ruff_source_file::{SourceFile, SourceLocation};
use ruff_text_size::{Ranged, TextRange, TextSize};
pub use text::TextEmitter;
use crate::jupyter::Notebook;
mod azure;
mod diff;
mod github;

View File

@@ -7,11 +7,11 @@ use annotate_snippets::snippet::{Annotation, AnnotationType, Slice, Snippet, Sou
use bitflags::bitflags;
use colored::Colorize;
use ruff_notebook::{Notebook, NotebookIndex};
use ruff_source_file::{OneIndexed, SourceLocation};
use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::fs::relativize_path;
use crate::jupyter::{Notebook, NotebookIndex};
use crate::line_width::{LineWidthBuilder, TabSize};
use crate::message::diff::Diff;
use crate::message::{Emitter, EmitterContext, Message};

View File

@@ -149,6 +149,17 @@ import os
# Content Content Content Content Content Content Content Content Content Content
# Copyright 2023
"#
.trim(),
&settings::Settings::for_rules(vec![Rule::MissingCopyrightNotice]),
);
assert_messages!(diagnostics);
}
#[test]
fn char_boundary() {
let diagnostics = test_snippet(
r#"কককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককক
"#
.trim(),
&settings::Settings::for_rules(vec![Rule::MissingCopyrightNotice]),

View File

@@ -1,8 +1,7 @@
use ruff_text_size::{TextRange, TextSize};
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_source_file::Locator;
use ruff_text_size::{TextRange, TextSize};
use crate::settings::Settings;
@@ -33,11 +32,7 @@ pub(crate) fn missing_copyright_notice(
}
// Only search the first 1024 bytes in the file.
let contents = if locator.len() < 1024 {
locator.contents()
} else {
locator.up_to(TextSize::from(1024))
};
let contents = locator.up_to(locator.floor_char_boundary(TextSize::new(1024)));
// Locate the copyright notice.
if let Some(match_) = settings.flake8_copyright.notice_rgx.find(contents) {

View File

@@ -0,0 +1,10 @@
---
source: crates/ruff/src/rules/flake8_copyright/mod.rs
---
<filename>:1:1: CPY001 Missing copyright notice at top of file
|
1 | কককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককককক
| CPY001
|

View File

@@ -1,7 +1,7 @@
use ruff_python_ast::{self as ast, Constant, Expr, Stmt};
use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::helpers::is_docstring_stmt;
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
@@ -23,18 +23,35 @@ impl AlwaysAutofixableViolation for NonEmptyStubBody {
/// PYI010
pub(crate) fn non_empty_stub_body(checker: &mut Checker, body: &[Stmt]) {
if let [Stmt::Expr(ast::StmtExpr { value, range: _ })] = body {
// Ignore multi-statement bodies (covered by PYI048).
let [stmt] = body else {
return;
};
// Ignore `pass` statements (covered by PYI009).
if stmt.is_pass_stmt() {
return;
}
// Ignore docstrings (covered by PYI021).
if is_docstring_stmt(stmt) {
return;
}
// Ignore `...` (the desired case).
if let Stmt::Expr(ast::StmtExpr { value, range: _ }) = stmt {
if let Expr::Constant(ast::ExprConstant { value, .. }) = value.as_ref() {
if matches!(value, Constant::Ellipsis | Constant::Str(_)) {
if value.is_ellipsis() {
return;
}
}
}
let mut diagnostic = Diagnostic::new(NonEmptyStubBody, body[0].range());
let mut diagnostic = Diagnostic::new(NonEmptyStubBody, stmt.range());
if checker.patch(Rule::NonEmptyStubBody) {
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
diagnostic.set_fix(Fix::suggested(Edit::range_replacement(
format!("..."),
body[0].range(),
stmt.range(),
)));
};
checker.diagnostics.push(diagnostic);

View File

@@ -22,17 +22,16 @@ impl AlwaysAutofixableViolation for PassStatementStubBody {
/// PYI009
pub(crate) fn pass_statement_stub_body(checker: &mut Checker, body: &[Stmt]) {
let [stmt] = body else {
let [Stmt::Pass(pass)] = body else {
return;
};
if stmt.is_pass_stmt() {
let mut diagnostic = Diagnostic::new(PassStatementStubBody, stmt.range());
if checker.patch(Rule::PassStatementStubBody) {
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
format!("..."),
stmt.range(),
)));
};
checker.diagnostics.push(diagnostic);
}
let mut diagnostic = Diagnostic::new(PassStatementStubBody, pass.range());
if checker.patch(Rule::PassStatementStubBody) {
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
format!("..."),
pass.range(),
)));
};
checker.diagnostics.push(diagnostic);
}

View File

@@ -1,9 +1,7 @@
use ruff_python_ast::Stmt;
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::helpers::is_docstring_stmt;
use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::Stmt;
use crate::checkers::ast::Checker;
@@ -17,21 +15,12 @@ impl Violation for StubBodyMultipleStatements {
}
}
/// PYI010
/// PYI048
pub(crate) fn stub_body_multiple_statements(checker: &mut Checker, stmt: &Stmt, body: &[Stmt]) {
// If the function body consists of exactly one statement, abort.
if body.len() == 1 {
return;
if body.len() > 1 {
checker.diagnostics.push(Diagnostic::new(
StubBodyMultipleStatements,
stmt.identifier(),
));
}
// If the function body consists of exactly two statements, and the first is a
// docstring, abort (this is covered by PYI021).
if body.len() == 2 && is_docstring_stmt(&body[0]) {
return;
}
checker.diagnostics.push(Diagnostic::new(
StubBodyMultipleStatements,
stmt.identifier(),
));
}

View File

@@ -11,8 +11,8 @@ PYI010.pyi:6:5: PYI010 [*] Function body must contain only `...`
|
= help: Replace function body with `...`
Fix
3 3 | """foo""" # OK, strings are handled by another rule
Suggested fix
3 3 | """foo""" # OK, docstrings are handled by another rule
4 4 |
5 5 | def buzz():
6 |- print("buzz") # ERROR PYI010
@@ -31,7 +31,7 @@ PYI010.pyi:9:5: PYI010 [*] Function body must contain only `...`
|
= help: Replace function body with `...`
Fix
Suggested fix
6 6 | print("buzz") # ERROR PYI010
7 7 |
8 8 | def foo2():
@@ -46,14 +46,19 @@ PYI010.pyi:12:5: PYI010 [*] Function body must contain only `...`
11 | def bizz():
12 | x = 123 # ERROR PYI010
| ^^^^^^^ PYI010
13 |
14 | def foo3():
|
= help: Replace function body with `...`
Fix
Suggested fix
9 9 | 123 # ERROR PYI010
10 10 |
11 11 | def bizz():
12 |- x = 123 # ERROR PYI010
12 |+ ... # ERROR PYI010
13 13 |
14 14 | def foo3():
15 15 | pass # OK, pass is handled by another rule

View File

@@ -1,17 +1,31 @@
---
source: crates/ruff/src/rules/flake8_pyi/mod.rs
---
PYI048.pyi:11:5: PYI048 Function body must contain exactly one statement
PYI048.pyi:8:5: PYI048 Function body must contain exactly one statement
|
11 | def foo(): # ERROR PYI048
6 | """oof""" # OK
7 |
8 | def oof(): # ERROR PYI048
| ^^^ PYI048
12 | """foo"""
13 | print("foo")
9 | """oof"""
10 | print("foo")
|
PYI048.pyi:12:5: PYI048 Function body must contain exactly one statement
|
10 | print("foo")
11 |
12 | def foo(): # ERROR PYI048
| ^^^ PYI048
13 | """foo"""
14 | print("foo")
|
PYI048.pyi:17:5: PYI048 Function body must contain exactly one statement
|
17 | def buzz(): # ERROR PYI048
15 | print("foo")
16 |
17 | def buzz(): # ERROR PYI048
| ^^^^ PYI048
18 | print("fizz")
19 | print("buzz")

View File

@@ -1,13 +1,13 @@
use ruff_python_ast::{self as ast, ElifElseClause, ExceptHandler, MatchCase, Stmt};
use ruff_text_size::{Ranged, TextRange, TextSize};
use std::iter::Peekable;
use std::slice;
use ruff_notebook::Notebook;
use ruff_python_ast::statement_visitor::StatementVisitor;
use ruff_python_ast::{self as ast, ElifElseClause, ExceptHandler, MatchCase, Stmt};
use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::directives::IsortDirectives;
use crate::jupyter::Notebook;
use crate::rules::isort::helpers;
use crate::source_kind::SourceKind;

View File

@@ -4,8 +4,8 @@ pub(crate) use ambiguous_variable_name::*;
pub(crate) use bare_except::*;
pub(crate) use compound_statements::*;
pub(crate) use doc_line_too_long::*;
pub use errors::IOError;
pub(crate) use errors::*;
pub use errors::{IOError, SyntaxError};
pub(crate) use imports::*;
pub(crate) use invalid_escape_sequence::*;

View File

@@ -1,5 +1,5 @@
---
source: crates/ruff/src/jupyter/notebook.rs
source: crates/ruff/src/linter.rs
---
isort.ipynb:cell 1:1:1: I001 [*] Import block is un-sorted or un-formatted
|

View File

@@ -1,5 +1,5 @@
---
source: crates/ruff/src/jupyter/notebook.rs
source: crates/ruff/src/linter.rs
---
ipy_escape_command.ipynb:cell 1:5:8: F401 [*] `os` imported but unused
|

View File

@@ -1,5 +1,5 @@
---
source: crates/ruff/src/jupyter/notebook.rs
source: crates/ruff/src/linter.rs
---
unused_variable.ipynb:cell 1:2:5: F841 [*] Local variable `foo1` is assigned to but never used
|

View File

@@ -1,5 +1,5 @@
use crate::autofix::source_map::SourceMap;
use crate::jupyter::Notebook;
use ruff_diagnostics::SourceMap;
use ruff_notebook::Notebook;
#[derive(Clone, Debug, PartialEq, is_macro::Is)]
pub enum SourceKind {

View File

@@ -21,7 +21,6 @@ use ruff_text_size::Ranged;
use crate::autofix::{fix_file, FixResult};
use crate::directives;
use crate::jupyter::Notebook;
use crate::linter::{check_path, LinterResult};
use crate::message::{Emitter, EmitterContext, Message, TextEmitter};
use crate::packaging::detect_package_root;
@@ -29,18 +28,7 @@ use crate::registry::AsRule;
use crate::rules::pycodestyle::rules::syntax_error;
use crate::settings::{flags, Settings};
use crate::source_kind::SourceKind;
#[cfg(not(fuzzing))]
pub(crate) fn read_jupyter_notebook(path: &Path) -> Result<Notebook> {
let path = test_resource_path("fixtures/jupyter").join(path);
Notebook::from_path(&path).map_err(|err| {
anyhow::anyhow!(
"Failed to read notebook file `{}`: {:?}",
path.display(),
err
)
})
}
use ruff_notebook::{Notebook, NotebookError};
#[cfg(not(fuzzing))]
pub(crate) fn test_resource_path(path: impl AsRef<Path>) -> std::path::PathBuf {
@@ -67,12 +55,12 @@ pub(crate) fn test_notebook_path(
path: impl AsRef<Path>,
expected: impl AsRef<Path>,
settings: &Settings,
) -> Result<TestedNotebook> {
let source_notebook = read_jupyter_notebook(path.as_ref())?;
) -> Result<TestedNotebook, NotebookError> {
let source_notebook = Notebook::from_path(path.as_ref())?;
let source_kind = SourceKind::IpyNotebook(source_notebook);
let (messages, transformed) = test_contents(&source_kind, path.as_ref(), settings);
let expected_notebook = read_jupyter_notebook(expected.as_ref())?;
let expected_notebook = Notebook::from_path(expected.as_ref())?;
let linted_notebook = transformed.into_owned().expect_ipy_notebook();
assert_eq!(
@@ -273,12 +261,8 @@ Source with applied fixes:
(messages, transformed)
}
fn print_diagnostics(
diagnostics: Vec<Diagnostic>,
file_path: &Path,
source: &SourceKind,
) -> String {
let filename = file_path.file_name().unwrap().to_string_lossy();
fn print_diagnostics(diagnostics: Vec<Diagnostic>, path: &Path, source: &SourceKind) -> String {
let filename = path.file_name().unwrap().to_string_lossy();
let source_file = SourceFileBuilder::new(filename.as_ref(), source.source_code()).finish();
let messages: Vec<_> = diagnostics
@@ -291,7 +275,7 @@ fn print_diagnostics(
.collect();
if let Some(notebook) = source.notebook() {
print_jupyter_messages(&messages, &filename, notebook)
print_jupyter_messages(&messages, path, notebook)
} else {
print_messages(&messages)
}
@@ -299,7 +283,7 @@ fn print_diagnostics(
pub(crate) fn print_jupyter_messages(
messages: &[Message],
filename: &str,
path: &Path,
notebook: &Notebook,
) -> String {
let mut output = Vec::new();
@@ -312,7 +296,7 @@ pub(crate) fn print_jupyter_messages(
&mut output,
messages,
&EmitterContext::new(&FxHashMap::from_iter([(
filename.to_string(),
path.file_name().unwrap().to_string_lossy().to_string(),
notebook.clone(),
)])),
)

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff_cli"
version = "0.0.286"
version = "0.0.287"
publish = false
authors = { workspace = true }
edition = { workspace = true }
@@ -25,6 +25,7 @@ ruff = { path = "../ruff", features = ["clap"] }
ruff_cache = { path = "../ruff_cache" }
ruff_diagnostics = { path = "../ruff_diagnostics" }
ruff_formatter = { path = "../ruff_formatter" }
ruff_notebook = { path = "../ruff_notebook" }
ruff_macros = { path = "../ruff_macros" }
ruff_python_ast = { path = "../ruff_python_ast" }
ruff_python_formatter = { path = "../ruff_python_formatter" }

View File

@@ -1,19 +1,19 @@
use crate::ExitStatus;
use anyhow::{anyhow, Result};
use ruff_workspace::options::Options;
#[allow(clippy::print_stdout)]
pub(crate) fn config(key: Option<&str>) -> ExitStatus {
pub(crate) fn config(key: Option<&str>) -> Result<()> {
match key {
None => print!("{}", Options::metadata()),
Some(key) => match Options::metadata().get(key) {
None => {
println!("Unknown option");
return ExitStatus::Error;
return Err(anyhow!("Unknown option: {key}"));
}
Some(entry) => {
print!("{entry}");
}
},
}
ExitStatus::Success
Ok(())
}

View File

@@ -1,8 +1,8 @@
#![cfg_attr(target_family = "wasm", allow(dead_code))]
use std::fs::write;
use std::fs::{write, File};
use std::io;
use std::io::Write;
use std::io::{BufWriter, Write};
use std::ops::AddAssign;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
@@ -14,18 +14,19 @@ use filetime::FileTime;
use log::{debug, error, warn};
use rustc_hash::FxHashMap;
use similar::TextDiff;
use thiserror::Error;
use ruff::jupyter::{Cell, Notebook};
use ruff::linter::{lint_fix, lint_only, FixTable, FixerResult, LinterResult};
use ruff::logging::DisplayParseError;
use ruff::message::Message;
use ruff::pyproject_toml::lint_pyproject_toml;
use ruff::registry::Rule;
use ruff::registry::AsRule;
use ruff::settings::{flags, AllSettings, Settings};
use ruff::source_kind::SourceKind;
use ruff::{fs, IOError};
use ruff::{fs, IOError, SyntaxError};
use ruff_diagnostics::Diagnostic;
use ruff_macros::CacheKey;
use ruff_notebook::{Cell, Notebook, NotebookError};
use ruff_python_ast::imports::ImportMap;
use ruff_python_ast::{PySourceType, SourceType, TomlSourceType};
use ruff_source_file::{LineIndex, SourceCode, SourceFileBuilder};
@@ -76,27 +77,39 @@ impl Diagnostics {
}
}
/// Generate [`Diagnostics`] based on an [`io::Error`].
pub(crate) fn from_io_error(err: &io::Error, path: &Path, settings: &Settings) -> Self {
if settings.rules.enabled(Rule::IOError) {
let io_err = Diagnostic::new(
IOError {
message: err.to_string(),
},
TextRange::default(),
);
let dummy = SourceFileBuilder::new(path.to_string_lossy().as_ref(), "").finish();
/// Generate [`Diagnostics`] based on a [`SourceExtractionError`].
pub(crate) fn from_source_error(
err: &SourceExtractionError,
path: Option<&Path>,
settings: &Settings,
) -> Self {
let diagnostic = Diagnostic::from(err);
if settings.rules.enabled(diagnostic.kind.rule()) {
let name = path.map_or_else(|| "-".into(), std::path::Path::to_string_lossy);
let dummy = SourceFileBuilder::new(name, "").finish();
Self::new(
vec![Message::from_diagnostic(io_err, dummy, TextSize::default())],
vec![Message::from_diagnostic(
diagnostic,
dummy,
TextSize::default(),
)],
ImportMap::default(),
)
} else {
warn!(
"{}{}{} {err}",
"Failed to lint ".bold(),
fs::relativize_path(path).bold(),
":".bold()
);
match path {
Some(path) => {
warn!(
"{}{}{} {err}",
"Failed to lint ".bold(),
fs::relativize_path(path).bold(),
":".bold()
);
}
None => {
warn!("{}{} {err}", "Failed to lint".bold(), ":".bold());
}
}
Self::default()
}
}
@@ -121,76 +134,6 @@ impl AddAssign for Diagnostics {
}
}
/// Read a Jupyter Notebook from disk.
///
/// Returns either an indexed Python Jupyter Notebook or a diagnostic (which is empty if we skip).
fn notebook_from_path(path: &Path) -> Result<Notebook, Box<Diagnostics>> {
let notebook = match Notebook::from_path(path) {
Ok(notebook) => {
if !notebook.is_python_notebook() {
// Not a python notebook, this could e.g. be an R notebook which we want to just skip.
debug!(
"Skipping {} because it's not a Python notebook",
path.display()
);
return Err(Box::default());
}
notebook
}
Err(diagnostic) => {
// Failed to read the jupyter notebook
return Err(Box::new(Diagnostics {
messages: vec![Message::from_diagnostic(
*diagnostic,
SourceFileBuilder::new(path.to_string_lossy().as_ref(), "").finish(),
TextSize::default(),
)],
..Diagnostics::default()
}));
}
};
Ok(notebook)
}
/// Parse a Jupyter Notebook from a JSON string.
///
/// Returns either an indexed Python Jupyter Notebook or a diagnostic (which is empty if we skip).
fn notebook_from_source_code(
source_code: &str,
path: Option<&Path>,
) -> Result<Notebook, Box<Diagnostics>> {
let notebook = match Notebook::from_source_code(source_code) {
Ok(notebook) => {
if !notebook.is_python_notebook() {
// Not a python notebook, this could e.g. be an R notebook which we want to just skip.
if let Some(path) = path {
debug!(
"Skipping {} because it's not a Python notebook",
path.display()
);
}
return Err(Box::default());
}
notebook
}
Err(diagnostic) => {
// Failed to read the jupyter notebook
return Err(Box::new(Diagnostics {
messages: vec![Message::from_diagnostic(
*diagnostic,
SourceFileBuilder::new(path.map(Path::to_string_lossy).unwrap_or_default(), "")
.finish(),
TextSize::default(),
)],
..Diagnostics::default()
}));
}
};
Ok(notebook)
}
/// Lint the source code at the given `Path`.
pub(crate) fn lint_path(
path: &Path,
@@ -235,12 +178,17 @@ pub(crate) fn lint_path(
.iter_enabled()
.any(|rule_code| rule_code.lint_source().is_pyproject_toml())
{
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) => {
return Ok(Diagnostics::from_io_error(&err, path, &settings.lib));
}
};
let contents =
match std::fs::read_to_string(path).map_err(SourceExtractionError::Io) {
Ok(contents) => contents,
Err(err) => {
return Ok(Diagnostics::from_source_error(
&err,
Some(path),
&settings.lib,
));
}
};
let source_file = SourceFileBuilder::new(path.to_string_lossy(), contents).finish();
lint_pyproject_toml(source_file, &settings.lib)
} else {
@@ -257,12 +205,14 @@ pub(crate) fn lint_path(
// Extract the sources from the file.
let LintSource(source_kind) = match LintSource::try_from_path(path, source_type) {
Ok(sources) => sources,
Err(SourceExtractionError::Io(err)) => {
return Ok(Diagnostics::from_io_error(&err, path, &settings.lib));
}
Err(SourceExtractionError::Diagnostics(diagnostics)) => {
return Ok(*diagnostics);
Ok(Some(sources)) => sources,
Ok(None) => return Ok(Diagnostics::default()),
Err(err) => {
return Ok(Diagnostics::from_source_error(
&err,
Some(path),
&settings.lib,
));
}
};
@@ -293,7 +243,8 @@ pub(crate) fn lint_path(
write(path, transformed.as_bytes())?;
}
SourceKind::IpyNotebook(notebook) => {
notebook.write(path)?;
let mut writer = BufWriter::new(File::create(path)?);
notebook.write(&mut writer)?;
}
},
flags::FixMode::Diff => {
@@ -443,17 +394,13 @@ pub(crate) fn lint_stdin(
};
// Extract the sources from the file.
let LintSource(source_kind) =
match LintSource::try_from_source_code(contents, path, source_type) {
Ok(sources) => sources,
Err(SourceExtractionError::Io(err)) => {
// SAFETY: An `io::Error` can only occur if we're reading from a path.
return Ok(Diagnostics::from_io_error(&err, path.unwrap(), settings));
}
Err(SourceExtractionError::Diagnostics(diagnostics)) => {
return Ok(*diagnostics);
}
};
let LintSource(source_kind) = match LintSource::try_from_source_code(contents, source_type) {
Ok(Some(sources)) => sources,
Ok(None) => return Ok(Diagnostics::default()),
Err(err) => {
return Ok(Diagnostics::from_source_error(&err, path, settings));
}
};
// Lint the inputs.
let (
@@ -563,15 +510,16 @@ impl LintSource {
fn try_from_path(
path: &Path,
source_type: PySourceType,
) -> Result<LintSource, SourceExtractionError> {
) -> Result<Option<LintSource>, SourceExtractionError> {
if source_type.is_ipynb() {
let notebook = notebook_from_path(path).map_err(SourceExtractionError::Diagnostics)?;
let source_kind = SourceKind::IpyNotebook(notebook);
Ok(LintSource(source_kind))
let notebook = Notebook::from_path(path)?;
Ok(notebook
.is_python_notebook()
.then_some(LintSource(SourceKind::IpyNotebook(notebook))))
} else {
// This is tested by ruff_cli integration test `unreadable_file`
let contents = std::fs::read_to_string(path).map_err(SourceExtractionError::Io)?;
Ok(LintSource(SourceKind::Python(contents)))
let contents = std::fs::read_to_string(path)?;
Ok(Some(LintSource(SourceKind::Python(contents))))
}
}
@@ -580,48 +528,53 @@ impl LintSource {
/// the file path should be used for diagnostics, but not for reading the file from disk.
fn try_from_source_code(
source_code: String,
path: Option<&Path>,
source_type: PySourceType,
) -> Result<LintSource, SourceExtractionError> {
) -> Result<Option<LintSource>, SourceExtractionError> {
if source_type.is_ipynb() {
let notebook = notebook_from_source_code(&source_code, path)
.map_err(SourceExtractionError::Diagnostics)?;
let source_kind = SourceKind::IpyNotebook(notebook);
Ok(LintSource(source_kind))
let notebook = Notebook::from_source_code(&source_code)?;
Ok(notebook
.is_python_notebook()
.then_some(LintSource(SourceKind::IpyNotebook(notebook))))
} else {
Ok(LintSource(SourceKind::Python(source_code)))
Ok(Some(LintSource(SourceKind::Python(source_code))))
}
}
}
#[derive(Debug)]
enum SourceExtractionError {
#[derive(Error, Debug)]
pub(crate) enum SourceExtractionError {
/// The extraction failed due to an [`io::Error`].
Io(io::Error),
/// The extraction failed, and generated [`Diagnostics`] to report.
Diagnostics(Box<Diagnostics>),
#[error(transparent)]
Io(#[from] io::Error),
/// The extraction failed due to a [`NotebookError`].
#[error(transparent)]
Notebook(#[from] NotebookError),
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::diagnostics::{notebook_from_path, notebook_from_source_code, Diagnostics};
#[test]
fn test_r() {
let path = Path::new("../ruff/resources/test/fixtures/jupyter/R.ipynb");
// No diagnostics is used as skip signal.
assert_eq!(
notebook_from_path(path).unwrap_err(),
Box::<Diagnostics>::default()
);
let contents = std::fs::read_to_string(path).unwrap();
// No diagnostics is used as skip signal.
assert_eq!(
notebook_from_source_code(&contents, Some(path)).unwrap_err(),
Box::<Diagnostics>::default()
);
impl From<&SourceExtractionError> for Diagnostic {
fn from(err: &SourceExtractionError) -> Self {
match err {
// IO errors.
SourceExtractionError::Io(_)
| SourceExtractionError::Notebook(NotebookError::Io(_) | NotebookError::Json(_)) => {
Diagnostic::new(
IOError {
message: err.to_string(),
},
TextRange::default(),
)
}
// Syntax errors.
SourceExtractionError::Notebook(
NotebookError::InvalidJson(_)
| NotebookError::InvalidSchema(_)
| NotebookError::InvalidFormat(_),
) => Diagnostic::new(
SyntaxError {
message: err.to_string(),
},
TextRange::default(),
),
}
}
}

View File

@@ -139,18 +139,27 @@ quoting the executed command, along with the relevant file contents and `pyproje
if let Some(rule) = rule {
commands::rule::rule(rule, format)?;
}
Ok(ExitStatus::Success)
}
Command::Config { option } => {
commands::config::config(option.as_deref())?;
Ok(ExitStatus::Success)
}
Command::Linter { format } => {
commands::linter::linter(format)?;
Ok(ExitStatus::Success)
}
Command::Clean => {
commands::clean::clean(log_level)?;
Ok(ExitStatus::Success)
}
Command::Config { option } => return Ok(commands::config::config(option.as_deref())),
Command::Linter { format } => commands::linter::linter(format)?,
Command::Clean => commands::clean::clean(log_level)?,
Command::GenerateShellCompletion { shell } => {
shell.generate(&mut Args::command(), &mut stdout());
Ok(ExitStatus::Success)
}
Command::Check(args) => return check(args, log_level),
Command::Format(args) => return format(args, log_level),
Command::Check(args) => check(args, log_level),
Command::Format(args) => format(args, log_level),
}
Ok(ExitStatus::Success)
}
fn format(args: FormatCommand, log_level: LogLevel) -> Result<ExitStatus> {

View File

@@ -18,6 +18,7 @@ ruff_formatter = { path = "../ruff_formatter" }
ruff_python_ast = { path = "../ruff_python_ast" }
ruff_python_codegen = { path = "../ruff_python_codegen" }
ruff_python_formatter = { path = "../ruff_python_formatter" }
ruff_notebook = { path = "../ruff_notebook" }
ruff_python_literal = { path = "../ruff_python_literal" }
ruff_python_parser = { path = "../ruff_python_parser" }
ruff_python_stdlib = { path = "../ruff_python_stdlib" }

View File

@@ -6,7 +6,6 @@ use std::path::PathBuf;
use anyhow::Result;
use ruff::jupyter;
use ruff_python_codegen::round_trip;
use ruff_python_stdlib::path::is_jupyter_notebook;
@@ -20,7 +19,7 @@ pub(crate) struct Args {
pub(crate) fn main(args: &Args) -> Result<()> {
let path = args.file.as_path();
if is_jupyter_notebook(path) {
println!("{}", jupyter::round_trip(path)?);
println!("{}", ruff_notebook::round_trip(path)?);
} else {
let contents = fs::read_to_string(&args.file)?;
println!("{}", round_trip(&contents, &args.file.to_string_lossy())?);

View File

@@ -1,9 +1,11 @@
pub use diagnostic::{Diagnostic, DiagnosticKind};
pub use edit::Edit;
pub use fix::{Applicability, Fix, IsolationLevel};
pub use source_map::{SourceMap, SourceMarker};
pub use violation::{AlwaysAutofixableViolation, AutofixKind, Violation};
mod diagnostic;
mod edit;
mod fix;
mod source_map;
mod violation;

View File

@@ -1,15 +1,29 @@
use ruff_text_size::{Ranged, TextSize};
use ruff_diagnostics::Edit;
use crate::Edit;
/// Lightweight sourcemap marker representing the source and destination
/// position for an [`Edit`].
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct SourceMarker {
pub struct SourceMarker {
/// Position of the marker in the original source.
pub(crate) source: TextSize,
source: TextSize,
/// Position of the marker in the transformed code.
pub(crate) dest: TextSize,
dest: TextSize,
}
impl SourceMarker {
pub fn new(source: TextSize, dest: TextSize) -> Self {
Self { source, dest }
}
pub const fn source(&self) -> TextSize {
self.source
}
pub const fn dest(&self) -> TextSize {
self.dest
}
}
/// A collection of [`SourceMarker`].
@@ -18,12 +32,12 @@ pub(crate) struct SourceMarker {
/// the transformed code. Here, only the boundaries of edits are tracked instead
/// of every single character.
#[derive(Default, PartialEq, Eq)]
pub(crate) struct SourceMap(Vec<SourceMarker>);
pub struct SourceMap(Vec<SourceMarker>);
impl SourceMap {
/// Returns a slice of all the markers in the sourcemap in the order they
/// were added.
pub(crate) fn markers(&self) -> &[SourceMarker] {
pub fn markers(&self) -> &[SourceMarker] {
&self.0
}
@@ -31,7 +45,7 @@ impl SourceMap {
///
/// The `output_length` is the length of the transformed string before the
/// edit is applied.
pub(crate) fn push_start_marker(&mut self, edit: &Edit, output_length: TextSize) {
pub fn push_start_marker(&mut self, edit: &Edit, output_length: TextSize) {
self.0.push(SourceMarker {
source: edit.start(),
dest: output_length,
@@ -42,7 +56,7 @@ impl SourceMap {
///
/// The `output_length` is the length of the transformed string after the
/// edit has been applied.
pub(crate) fn push_end_marker(&mut self, edit: &Edit, output_length: TextSize) {
pub fn push_end_marker(&mut self, edit: &Edit, output_length: TextSize) {
if edit.is_insertion() {
self.0.push(SourceMarker {
source: edit.start(),

View File

@@ -0,0 +1,31 @@
[package]
name = "ruff_notebook"
version = "0.0.0"
publish = false
authors = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
homepage = { workspace = true }
documentation = { workspace = true }
repository = { workspace = true }
license = { workspace = true }
[lib]
[dependencies]
ruff_diagnostics = { path = "../ruff_diagnostics" }
ruff_source_file = { path = "../ruff_source_file" }
ruff_text_size = { path = "../ruff_text_size" }
anyhow = { workspace = true }
itertools = { workspace = true }
once_cell = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_with = { version = "3.0.0" }
thiserror = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
insta = { workspace = true }
test-case = { workspace = true }

View File

@@ -1,29 +1,23 @@
use std::cmp::Ordering;
use std::fmt::Display;
use std::fs::File;
use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
use std::iter;
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write};
use std::path::Path;
use std::{io, iter};
use itertools::Itertools;
use once_cell::sync::OnceCell;
use serde::Serialize;
use serde_json::error::Category;
use thiserror::Error;
use uuid::Uuid;
use ruff_diagnostics::Diagnostic;
use ruff_python_parser::lexer::lex;
use ruff_python_parser::Mode;
use ruff_diagnostics::{SourceMap, SourceMarker};
use ruff_source_file::{NewlineWithTrailingNewline, UniversalNewlineIterator};
use ruff_text_size::{TextRange, TextSize};
use ruff_text_size::TextSize;
use crate::autofix::source_map::{SourceMap, SourceMarker};
use crate::jupyter::index::NotebookIndex;
use crate::jupyter::schema::{Cell, RawNotebook, SortAlphabetically, SourceValue};
use crate::rules::pycodestyle::rules::SyntaxError;
use crate::IOError;
pub const JUPYTER_NOTEBOOK_EXT: &str = "ipynb";
use crate::index::NotebookIndex;
use crate::schema::{Cell, RawNotebook, SortAlphabetically, SourceValue};
/// Run round-trip source code generation on a given Jupyter notebook file path.
pub fn round_trip(path: &Path) -> anyhow::Result<String> {
@@ -37,7 +31,7 @@ pub fn round_trip(path: &Path) -> anyhow::Result<String> {
let code = notebook.source_code().to_string();
notebook.update_cell_content(&code);
let mut writer = Vec::new();
notebook.write_inner(&mut writer)?;
notebook.write(&mut writer)?;
Ok(String::from_utf8(writer)?)
}
@@ -96,6 +90,21 @@ impl Cell {
}
}
/// An error that can occur while deserializing a Jupyter Notebook.
#[derive(Error, Debug)]
pub enum NotebookError {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(serde_json::Error),
#[error("Expected a Jupyter Notebook, which must be internally stored as JSON, but this file isn't valid JSON: {0}")]
InvalidJson(serde_json::Error),
#[error("This file does not match the schema expected of Jupyter Notebooks: {0}")]
InvalidSchema(serde_json::Error),
#[error("Expected Jupyter Notebook format 4, found: {0}")]
InvalidFormat(i64),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Notebook {
/// Python source code of the notebook.
@@ -121,19 +130,12 @@ pub struct Notebook {
impl Notebook {
/// Read the Jupyter Notebook from the given [`Path`].
pub fn from_path(path: &Path) -> Result<Self, Box<Diagnostic>> {
Self::from_reader(BufReader::new(File::open(path).map_err(|err| {
Diagnostic::new(
IOError {
message: format!("{err}"),
},
TextRange::default(),
)
})?))
pub fn from_path(path: &Path) -> Result<Self, NotebookError> {
Self::from_reader(BufReader::new(File::open(path)?))
}
/// Read the Jupyter Notebook from its JSON string.
pub fn from_source_code(source_code: &str) -> Result<Self, Box<Diagnostic>> {
pub fn from_source_code(source_code: &str) -> Result<Self, NotebookError> {
Self::from_reader(Cursor::new(source_code))
}
@@ -141,7 +143,7 @@ impl Notebook {
///
/// See also the black implementation
/// <https://github.com/psf/black/blob/69ca0a4c7a365c5f5eea519a90980bab72cab764/src/black/__init__.py#L1017-L1046>
fn from_reader<R>(mut reader: R) -> Result<Self, Box<Diagnostic>>
fn from_reader<R>(mut reader: R) -> Result<Self, NotebookError>
where
R: Read + Seek,
{
@@ -149,95 +151,27 @@ impl Notebook {
let mut buf = [0; 1];
reader.read_exact(&mut buf).is_ok_and(|_| buf[0] == b'\n')
});
reader.rewind().map_err(|err| {
Diagnostic::new(
IOError {
message: format!("{err}"),
},
TextRange::default(),
)
})?;
reader.rewind()?;
let mut raw_notebook: RawNotebook = match serde_json::from_reader(reader.by_ref()) {
Ok(notebook) => notebook,
Err(err) => {
// Translate the error into a diagnostic
return Err(Box::new({
match err.classify() {
Category::Io => Diagnostic::new(
IOError {
message: format!("{err}"),
},
TextRange::default(),
),
Category::Syntax | Category::Eof => {
// Maybe someone saved the python sources (those with the `# %%` separator)
// as jupyter notebook instead. Let's help them.
let mut contents = String::new();
reader
.rewind()
.and_then(|_| reader.read_to_string(&mut contents))
.map_err(|err| {
Diagnostic::new(
IOError {
message: format!("{err}"),
},
TextRange::default(),
)
})?;
// Check if tokenizing was successful and the file is non-empty
if lex(&contents, Mode::Module).any(|result| result.is_err()) {
Diagnostic::new(
SyntaxError {
message: format!(
"A Jupyter Notebook (.{JUPYTER_NOTEBOOK_EXT}) must internally be JSON, \
but this file isn't valid JSON: {err}"
),
},
TextRange::default(),
)
} else {
Diagnostic::new(
SyntaxError {
message: format!(
"Expected a Jupyter Notebook (.{JUPYTER_NOTEBOOK_EXT}), \
which must be internally stored as JSON, \
but found a Python source file: {err}"
),
},
TextRange::default(),
)
}
}
Category::Data => {
// We could try to read the schema version here but if this fails it's
// a bug anyway
Diagnostic::new(
SyntaxError {
message: format!(
"This file does not match the schema expected of Jupyter Notebooks: {err}"
),
},
TextRange::default(),
)
}
return Err(match err.classify() {
Category::Io => NotebookError::Json(err),
Category::Syntax | Category::Eof => NotebookError::InvalidJson(err),
Category::Data => {
// We could try to read the schema version here but if this fails it's
// a bug anyway.
NotebookError::InvalidSchema(err)
}
}));
});
}
};
// v4 is what everybody uses
if raw_notebook.nbformat != 4 {
// bail because we should have already failed at the json schema stage
return Err(Box::new(Diagnostic::new(
SyntaxError {
message: format!(
"Expected Jupyter Notebook format 4, found {}",
raw_notebook.nbformat
),
},
TextRange::default(),
)));
return Err(NotebookError::InvalidFormat(raw_notebook.nbformat));
}
let valid_code_cells = raw_notebook
@@ -304,13 +238,13 @@ impl Notebook {
// The first offset is always going to be at 0, so skip it.
for offset in self.cell_offsets.iter_mut().skip(1).rev() {
let closest_marker = match last_marker {
Some(marker) if marker.source <= *offset => marker,
Some(marker) if marker.source() <= *offset => marker,
_ => {
let Some(marker) = source_map
.markers()
.iter()
.rev()
.find(|m| m.source <= *offset)
.find(|marker| marker.source() <= *offset)
else {
// There are no markers above the current offset, so we can
// stop here.
@@ -321,9 +255,9 @@ impl Notebook {
}
};
match closest_marker.source.cmp(&closest_marker.dest) {
Ordering::Less => *offset += closest_marker.dest - closest_marker.source,
Ordering::Greater => *offset -= closest_marker.source - closest_marker.dest,
match closest_marker.source().cmp(&closest_marker.dest()) {
Ordering::Less => *offset += closest_marker.dest() - closest_marker.source(),
Ordering::Greater => *offset -= closest_marker.source() - closest_marker.dest(),
Ordering::Equal => (),
}
}
@@ -431,18 +365,23 @@ impl Notebook {
/// The index is built only once when required. This is only used to
/// report diagnostics, so by that time all of the autofixes must have
/// been applied if `--fix` was passed.
pub(crate) fn index(&self) -> &NotebookIndex {
pub fn index(&self) -> &NotebookIndex {
self.index.get_or_init(|| self.build_index())
}
/// Return the cell offsets for the concatenated source code corresponding
/// the Jupyter notebook.
pub(crate) fn cell_offsets(&self) -> &[TextSize] {
pub fn cell_offsets(&self) -> &[TextSize] {
&self.cell_offsets
}
/// Return `true` if the notebook has a trailing newline, `false` otherwise.
pub fn trailing_newline(&self) -> bool {
self.trailing_newline
}
/// Update the notebook with the given sourcemap and transformed content.
pub(crate) fn update(&mut self, source_map: &SourceMap, transformed: String) {
pub fn update(&mut self, source_map: &SourceMap, transformed: String) {
// Cell offsets must be updated before updating the cell content as
// it depends on the offsets to extract the cell content.
self.index.take();
@@ -465,7 +404,8 @@ impl Notebook {
.map_or(true, |language| language.name == "python")
}
fn write_inner(&self, writer: &mut impl Write) -> anyhow::Result<()> {
/// Write the notebook back to the given [`Write`] implementor.
pub fn write(&self, writer: &mut dyn Write) -> anyhow::Result<()> {
// https://github.com/psf/black/blob/69ca0a4c7a365c5f5eea519a90980bab72cab764/src/black/__init__.py#LL1041
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut serializer = serde_json::Serializer::with_formatter(writer, formatter);
@@ -475,13 +415,6 @@ impl Notebook {
}
Ok(())
}
/// Write back with an indent of 1, just like black
pub fn write(&self, path: &Path) -> anyhow::Result<()> {
let mut writer = BufWriter::new(File::create(path)?);
self.write_inner(&mut writer)?;
Ok(())
}
}
#[cfg(test)]
@@ -491,58 +424,41 @@ mod tests {
use anyhow::Result;
use test_case::test_case;
use crate::jupyter::index::NotebookIndex;
use crate::jupyter::schema::Cell;
use crate::jupyter::Notebook;
use crate::registry::Rule;
use crate::source_kind::SourceKind;
use crate::test::{
read_jupyter_notebook, test_contents, test_notebook_path, test_resource_path,
TestedNotebook,
};
use crate::{assert_messages, settings};
use crate::{Cell, Notebook, NotebookError, NotebookIndex};
/// Read a Jupyter cell from the `resources/test/fixtures/jupyter/cell` directory.
fn read_jupyter_cell(path: impl AsRef<Path>) -> Result<Cell> {
let path = test_resource_path("fixtures/jupyter/cell").join(path);
let source_code = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&source_code)?)
/// Construct a path to a Jupyter notebook in the `resources/test/fixtures/jupyter` directory.
fn notebook_path(path: impl AsRef<Path>) -> std::path::PathBuf {
Path::new("./resources/test/fixtures/jupyter").join(path)
}
#[test]
fn test_valid() {
assert!(read_jupyter_notebook(Path::new("valid.ipynb")).is_ok());
fn test_python() -> Result<(), NotebookError> {
let notebook = Notebook::from_path(&notebook_path("valid.ipynb"))?;
assert!(notebook.is_python_notebook());
Ok(())
}
#[test]
fn test_r() {
// We can load this, it will be filtered out later
assert!(read_jupyter_notebook(Path::new("R.ipynb")).is_ok());
fn test_r() -> Result<(), NotebookError> {
let notebook = Notebook::from_path(&notebook_path("R.ipynb"))?;
assert!(!notebook.is_python_notebook());
Ok(())
}
#[test]
fn test_invalid() {
let path = Path::new("resources/test/fixtures/jupyter/invalid_extension.ipynb");
assert_eq!(
Notebook::from_path(path).unwrap_err().kind.body,
"SyntaxError: Expected a Jupyter Notebook (.ipynb), \
which must be internally stored as JSON, \
but found a Python source file: \
expected value at line 1 column 1"
);
let path = Path::new("resources/test/fixtures/jupyter/not_json.ipynb");
assert_eq!(
Notebook::from_path(path).unwrap_err().kind.body,
"SyntaxError: A Jupyter Notebook (.ipynb) must internally be JSON, \
but this file isn't valid JSON: \
expected value at line 1 column 1"
);
let path = Path::new("resources/test/fixtures/jupyter/wrong_schema.ipynb");
assert_eq!(
Notebook::from_path(path).unwrap_err().kind.body,
"SyntaxError: This file does not match the schema expected of Jupyter Notebooks: \
missing field `cells` at line 1 column 2"
);
assert!(matches!(
Notebook::from_path(&notebook_path("invalid_extension.ipynb")),
Err(NotebookError::InvalidJson(_))
));
assert!(matches!(
Notebook::from_path(&notebook_path("not_json.ipynb")),
Err(NotebookError::InvalidJson(_))
));
assert!(matches!(
Notebook::from_path(&notebook_path("wrong_schema.ipynb")),
Err(NotebookError::InvalidSchema(_))
));
}
#[test_case(Path::new("markdown.json"), false; "markdown")]
@@ -551,13 +467,20 @@ mod tests {
#[test_case(Path::new("only_code.json"), true; "only_code")]
#[test_case(Path::new("cell_magic.json"), false; "cell_magic")]
fn test_is_valid_code_cell(path: &Path, expected: bool) -> Result<()> {
/// Read a Jupyter cell from the `resources/test/fixtures/jupyter/cell` directory.
fn read_jupyter_cell(path: impl AsRef<Path>) -> Result<Cell> {
let path = notebook_path("cell").join(path);
let source_code = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&source_code)?)
}
assert_eq!(read_jupyter_cell(path)?.is_valid_code_cell(), expected);
Ok(())
}
#[test]
fn test_concat_notebook() -> Result<()> {
let notebook = read_jupyter_notebook(Path::new("valid.ipynb"))?;
fn test_concat_notebook() -> Result<(), NotebookError> {
let notebook = Notebook::from_path(&notebook_path("valid.ipynb"))?;
assert_eq!(
notebook.source_code,
r#"def unused_variable():
@@ -597,110 +520,4 @@ print("after empty cells")
);
Ok(())
}
#[test]
fn test_import_sorting() -> Result<()> {
let path = "isort.ipynb".to_string();
let TestedNotebook {
messages,
source_notebook,
..
} = test_notebook_path(
&path,
Path::new("isort_expected.ipynb"),
&settings::Settings::for_rule(Rule::UnsortedImports),
)?;
assert_messages!(messages, path, source_notebook);
Ok(())
}
#[test]
fn test_ipy_escape_command() -> Result<()> {
let path = "ipy_escape_command.ipynb".to_string();
let TestedNotebook {
messages,
source_notebook,
..
} = test_notebook_path(
&path,
Path::new("ipy_escape_command_expected.ipynb"),
&settings::Settings::for_rule(Rule::UnusedImport),
)?;
assert_messages!(messages, path, source_notebook);
Ok(())
}
#[test]
fn test_unused_variable() -> Result<()> {
let path = "unused_variable.ipynb".to_string();
let TestedNotebook {
messages,
source_notebook,
..
} = test_notebook_path(
&path,
Path::new("unused_variable_expected.ipynb"),
&settings::Settings::for_rule(Rule::UnusedVariable),
)?;
assert_messages!(messages, path, source_notebook);
Ok(())
}
#[test]
fn test_json_consistency() -> Result<()> {
let path = "before_fix.ipynb".to_string();
let TestedNotebook {
linted_notebook: fixed_notebook,
..
} = test_notebook_path(
path,
Path::new("after_fix.ipynb"),
&settings::Settings::for_rule(Rule::UnusedImport),
)?;
let mut writer = Vec::new();
fixed_notebook.write_inner(&mut writer)?;
let actual = String::from_utf8(writer)?;
let expected =
std::fs::read_to_string(test_resource_path("fixtures/jupyter/after_fix.ipynb"))?;
assert_eq!(actual, expected);
Ok(())
}
#[test_case(Path::new("before_fix.ipynb"), true; "trailing_newline")]
#[test_case(Path::new("no_trailing_newline.ipynb"), false; "no_trailing_newline")]
fn test_trailing_newline(path: &Path, trailing_newline: bool) -> Result<()> {
let notebook = read_jupyter_notebook(path)?;
assert_eq!(notebook.trailing_newline, trailing_newline);
let mut writer = Vec::new();
notebook.write_inner(&mut writer)?;
let string = String::from_utf8(writer)?;
assert_eq!(string.ends_with('\n'), trailing_newline);
Ok(())
}
// Version <4.5, don't emit cell ids
#[test_case(Path::new("no_cell_id.ipynb"), false; "no_cell_id")]
// Version 4.5, cell ids are missing and need to be added
#[test_case(Path::new("add_missing_cell_id.ipynb"), true; "add_missing_cell_id")]
fn test_cell_id(path: &Path, has_id: bool) -> Result<()> {
let source_notebook = read_jupyter_notebook(path)?;
let source_kind = SourceKind::IpyNotebook(source_notebook);
let (_, transformed) = test_contents(
&source_kind,
path,
&settings::Settings::for_rule(Rule::UnusedImport),
);
let linted_notebook = transformed.into_owned().expect_ipy_notebook();
let mut writer = Vec::new();
linted_notebook.write_inner(&mut writer)?;
let actual = String::from_utf8(writer)?;
if has_id {
assert!(actual.contains(r#""id": ""#));
} else {
assert!(!actual.contains(r#""id":"#));
}
Ok(())
}
}

View File

@@ -46,7 +46,7 @@ fn sort_alphabetically<T: Serialize, S: serde::Serializer>(
///
/// use serde::Serialize;
///
/// use ruff::jupyter::SortAlphabetically;
/// use ruff_notebook::SortAlphabetically;
///
/// #[derive(Serialize)]
/// struct MyStruct {

View File

@@ -2716,7 +2716,7 @@ impl Ranged for crate::nodes::StmtContinue {
self.range
}
}
impl Ranged for StmtIpyEscapeCommand {
impl Ranged for crate::nodes::StmtIpyEscapeCommand {
fn range(&self) -> TextRange {
self.range
}
@@ -2888,7 +2888,7 @@ impl Ranged for crate::nodes::ExprSlice {
self.range
}
}
impl Ranged for ExprIpyEscapeCommand {
impl Ranged for crate::nodes::ExprIpyEscapeCommand {
fn range(&self) -> TextRange {
self.range
}
@@ -2927,7 +2927,6 @@ impl Ranged for crate::Expr {
}
}
}
impl Ranged for crate::nodes::Comprehension {
fn range(&self) -> TextRange {
self.range
@@ -2945,7 +2944,6 @@ impl Ranged for crate::ExceptHandler {
}
}
}
impl Ranged for crate::nodes::Parameter {
fn range(&self) -> TextRange {
self.range
@@ -3086,6 +3084,173 @@ impl Ranged for crate::nodes::ParameterWithDefault {
}
}
/// An expression that may be parenthesized.
#[derive(Clone, Debug)]
pub struct ParenthesizedExpr {
/// The range of the expression, including any parentheses.
pub range: TextRange,
/// The underlying expression.
pub expr: Expr,
}
impl Ranged for ParenthesizedExpr {
fn range(&self) -> TextRange {
self.range
}
}
impl From<Expr> for ParenthesizedExpr {
fn from(expr: Expr) -> Self {
ParenthesizedExpr {
range: expr.range(),
expr,
}
}
}
impl From<ParenthesizedExpr> for Expr {
fn from(parenthesized_expr: ParenthesizedExpr) -> Self {
parenthesized_expr.expr
}
}
impl From<ExprIpyEscapeCommand> for ParenthesizedExpr {
fn from(payload: ExprIpyEscapeCommand) -> Self {
Expr::IpyEscapeCommand(payload).into()
}
}
impl From<ExprBoolOp> for ParenthesizedExpr {
fn from(payload: ExprBoolOp) -> Self {
Expr::BoolOp(payload).into()
}
}
impl From<ExprNamedExpr> for ParenthesizedExpr {
fn from(payload: ExprNamedExpr) -> Self {
Expr::NamedExpr(payload).into()
}
}
impl From<ExprBinOp> for ParenthesizedExpr {
fn from(payload: ExprBinOp) -> Self {
Expr::BinOp(payload).into()
}
}
impl From<ExprUnaryOp> for ParenthesizedExpr {
fn from(payload: ExprUnaryOp) -> Self {
Expr::UnaryOp(payload).into()
}
}
impl From<ExprLambda> for ParenthesizedExpr {
fn from(payload: ExprLambda) -> Self {
Expr::Lambda(payload).into()
}
}
impl From<ExprIfExp> for ParenthesizedExpr {
fn from(payload: ExprIfExp) -> Self {
Expr::IfExp(payload).into()
}
}
impl From<ExprDict> for ParenthesizedExpr {
fn from(payload: ExprDict) -> Self {
Expr::Dict(payload).into()
}
}
impl From<ExprSet> for ParenthesizedExpr {
fn from(payload: ExprSet) -> Self {
Expr::Set(payload).into()
}
}
impl From<ExprListComp> for ParenthesizedExpr {
fn from(payload: ExprListComp) -> Self {
Expr::ListComp(payload).into()
}
}
impl From<ExprSetComp> for ParenthesizedExpr {
fn from(payload: ExprSetComp) -> Self {
Expr::SetComp(payload).into()
}
}
impl From<ExprDictComp> for ParenthesizedExpr {
fn from(payload: ExprDictComp) -> Self {
Expr::DictComp(payload).into()
}
}
impl From<ExprGeneratorExp> for ParenthesizedExpr {
fn from(payload: ExprGeneratorExp) -> Self {
Expr::GeneratorExp(payload).into()
}
}
impl From<ExprAwait> for ParenthesizedExpr {
fn from(payload: ExprAwait) -> Self {
Expr::Await(payload).into()
}
}
impl From<ExprYield> for ParenthesizedExpr {
fn from(payload: ExprYield) -> Self {
Expr::Yield(payload).into()
}
}
impl From<ExprYieldFrom> for ParenthesizedExpr {
fn from(payload: ExprYieldFrom) -> Self {
Expr::YieldFrom(payload).into()
}
}
impl From<ExprCompare> for ParenthesizedExpr {
fn from(payload: ExprCompare) -> Self {
Expr::Compare(payload).into()
}
}
impl From<ExprCall> for ParenthesizedExpr {
fn from(payload: ExprCall) -> Self {
Expr::Call(payload).into()
}
}
impl From<ExprFormattedValue> for ParenthesizedExpr {
fn from(payload: ExprFormattedValue) -> Self {
Expr::FormattedValue(payload).into()
}
}
impl From<ExprFString> for ParenthesizedExpr {
fn from(payload: ExprFString) -> Self {
Expr::FString(payload).into()
}
}
impl From<ExprConstant> for ParenthesizedExpr {
fn from(payload: ExprConstant) -> Self {
Expr::Constant(payload).into()
}
}
impl From<ExprAttribute> for ParenthesizedExpr {
fn from(payload: ExprAttribute) -> Self {
Expr::Attribute(payload).into()
}
}
impl From<ExprSubscript> for ParenthesizedExpr {
fn from(payload: ExprSubscript) -> Self {
Expr::Subscript(payload).into()
}
}
impl From<ExprStarred> for ParenthesizedExpr {
fn from(payload: ExprStarred) -> Self {
Expr::Starred(payload).into()
}
}
impl From<ExprName> for ParenthesizedExpr {
fn from(payload: ExprName) -> Self {
Expr::Name(payload).into()
}
}
impl From<ExprList> for ParenthesizedExpr {
fn from(payload: ExprList) -> Self {
Expr::List(payload).into()
}
}
impl From<ExprTuple> for ParenthesizedExpr {
fn from(payload: ExprTuple) -> Self {
Expr::Tuple(payload).into()
}
}
impl From<ExprSlice> for ParenthesizedExpr {
fn from(payload: ExprSlice) -> Self {
Expr::Slice(payload).into()
}
}
#[cfg(target_pointer_width = "64")]
mod size_assertions {
use static_assertions::assert_eq_size;

View File

@@ -33,3 +33,27 @@ result_f = (
# comment
''
)
(
f'{1}' # comment
f'{2}'
)
(
f'{1}'
f'{2}' # comment
)
(
1, ( # comment
f'{2}'
)
)
(
(
f'{1}'
# comment
),
2
)

View File

@@ -1,55 +0,0 @@
def test():
# fmt: off
a_very_small_indent
(
not_fixed
)
if True:
pass
more
# fmt: on
formatted
def test():
a_small_indent
# fmt: off
# fix under-indented comments
(or_the_inner_expression +
expressions
)
if True:
pass
# fmt: on
# fmt: off
def test():
pass
# It is necessary to indent comments because the following fmt: on comment because it otherwise becomes a trailing comment
# of the `test` function if the "proper" indentation is larger than 2 spaces.
# fmt: on
disabled + formatting;
# fmt: on
formatted;
def test():
pass
# fmt: off
"""A multiline strings
that should not get formatted"""
"A single quoted multiline \
string"
disabled + formatting;
# fmt: on
formatted;

View File

@@ -0,0 +1,36 @@
def func():
pass
# fmt: off
x = 1
# fmt: on
# fmt: off
def func():
pass
# fmt: on
x = 1
# fmt: off
def func():
pass
# fmt: on
def func():
pass
# fmt: off
def func():
pass
# fmt: off
def func():
pass
# fmt: on
def func():
pass
# fmt: on
def func():
pass

View File

@@ -0,0 +1,161 @@
###
# Blank lines around functions
###
x = 1
# comment
def f():
pass
if True:
x = 1
# comment
def f():
pass
x = 1
# comment
def f():
pass
x = 1
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
# comment
def f():
pass
# comment
def f():
pass
# comment
###
# Blank lines around imports.
###
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x # comment
# comment
import y
def f(): pass # comment
# comment
x = 1
def f():
pass
# comment
x = 1

View File

@@ -1,6 +1,29 @@
# Pragma reserved width fixtures
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # type: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pyright: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pylint: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # nocoverage: This should break
# Pragma fixtures for non-breaking space (lead by NBSP)
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # type: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pyright: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pylint: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # nocoverage: This should break
# As of adding this fixture Black adds a space before the non-breaking space if part of a type pragma.
# https://github.com/psf/black/blob/b4dca26c7d93f930bbd5a7b552807370b60d4298/src/black/comments.py#L122-L129
i2 = "" #  type: Add space before leading NBSP followed by spaces
i3 = "" #type: A space is added
i4 = "" #  type: Add space before leading NBSP followed by a space
i5 = "" # type: Add space before leading NBSP
i = "" #  type: Add space before leading NBSP followed by spaces
i = "" #type: A space is added
i = "" #  type: Add space before leading NBSP followed by a space
i = "" # type: Add space before leading NBSP
i = "" #  type: Add space before two leading NBSP
# A noqa as `#\u{A0}\u{A0}noqa` becomes `# \u{A0}noqa`
i = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" #  noqa

View File

@@ -1,11 +1,11 @@
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use ruff_text_size::{Ranged, TextLen, TextRange};
use unicode_width::UnicodeWidthChar;
use ruff_formatter::{format_args, write, FormatError, FormatOptions, SourceCode};
use ruff_python_ast::node::{AnyNodeRef, AstNode};
use ruff_python_trivia::{lines_after, lines_after_ignoring_trivia, lines_before};
use ruff_text_size::{Ranged, TextLen, TextRange};
use crate::comments::{CommentLinePosition, SourceComment};
use crate::context::NodeLevel;
@@ -299,10 +299,10 @@ impl Format<PyFormatContext<'_>> for FormatComment<'_> {
}
}
// Helper that inserts the appropriate number of empty lines before a comment, depending on the node level.
// Top level: Up to two empty lines
// parenthesized: A single empty line
// other: Up to a single empty line
/// Helper that inserts the appropriate number of empty lines before a comment, depending on the node level:
/// - Top-level: Up to two empty lines.
/// - Parenthesized: A single empty line.
/// - Otherwise: Up to a single empty line.
pub(crate) const fn empty_lines(lines: u32) -> FormatEmptyLines {
FormatEmptyLines { lines }
}
@@ -357,17 +357,33 @@ impl Format<PyFormatContext<'_>> for FormatTrailingEndOfLineComment<'_> {
let normalized_comment = normalize_comment(self.comment, source)?;
// Start with 2 because of the two leading spaces.
let mut reserved_width = 2;
// Trim the normalized comment to detect excluded pragmas (strips NBSP).
let trimmed = strip_comment_prefix(&normalized_comment)?.trim_start();
// SAFE: The formatted file is <= 4GB, and each comment should as well.
#[allow(clippy::cast_possible_truncation)]
for c in normalized_comment.chars() {
reserved_width += match c {
'\t' => f.options().tab_width().value(),
c => c.width().unwrap_or(0) as u32,
let is_pragma = if let Some((maybe_pragma, _)) = trimmed.split_once(':') {
matches!(maybe_pragma, "noqa" | "type" | "pyright" | "pylint")
} else {
trimmed.starts_with("noqa")
};
// Don't reserve width for excluded pragma comments.
let reserved_width = if is_pragma {
0
} else {
// Start with 2 because of the two leading spaces.
let mut width = 2;
// SAFETY: The formatted file is <= 4GB, and each comment should as well.
#[allow(clippy::cast_possible_truncation)]
for c in normalized_comment.chars() {
width += match c {
'\t' => f.options().tab_width().value(),
c => c.width().unwrap_or(0) as u32,
}
}
}
width
};
write!(
f,
@@ -442,11 +458,7 @@ fn normalize_comment<'a>(
let trimmed = comment_text.trim_end();
let Some(content) = trimmed.strip_prefix('#') else {
return Err(FormatError::syntax_error(
"Didn't find expected comment token `#`",
));
};
let content = strip_comment_prefix(trimmed)?;
if content.is_empty() {
return Ok(Cow::Borrowed("#"));
@@ -462,16 +474,70 @@ fn normalize_comment<'a>(
if content.starts_with('\u{A0}') {
let trimmed = content.trim_start_matches('\u{A0}');
// Black adds a space before the non-breaking space if part of a type pragma.
if trimmed.trim_start().starts_with("type:") {
return Ok(Cow::Owned(std::format!("# \u{A0}{trimmed}")));
}
// Black replaces the non-breaking space with a space if followed by a space.
if trimmed.starts_with(' ') {
return Ok(Cow::Owned(std::format!("# {trimmed}")));
// Black adds a space before the non-breaking space if part of a type pragma.
Ok(Cow::Owned(std::format!("# {content}")))
} else if trimmed.starts_with(' ') {
// Black replaces the non-breaking space with a space if followed by a space.
Ok(Cow::Owned(std::format!("# {trimmed}")))
} else {
// Otherwise we replace the first non-breaking space with a regular space.
Ok(Cow::Owned(std::format!("# {}", &content["\u{A0}".len()..])))
}
} else {
Ok(Cow::Owned(std::format!("# {}", content.trim_start())))
}
}
/// A helper for stripping '#' from comments.
fn strip_comment_prefix(comment_text: &str) -> FormatResult<&str> {
let Some(content) = comment_text.strip_prefix('#') else {
return Err(FormatError::syntax_error(
"Didn't find expected comment token `#`",
));
};
Ok(content)
}
/// Format the empty lines between a node and its trailing comments.
///
/// For example, given:
/// ```python
/// def func():
/// ...
/// # comment
/// ```
///
/// This builder will insert two empty lines before the comment.
/// ```
pub(crate) const fn empty_lines_before_trailing_comments(
comments: &[SourceComment],
expected: u32,
) -> FormatEmptyLinesBeforeTrailingComments {
FormatEmptyLinesBeforeTrailingComments { comments, expected }
}
#[derive(Copy, Clone, Debug)]
pub(crate) struct FormatEmptyLinesBeforeTrailingComments<'a> {
/// The trailing comments of the node.
comments: &'a [SourceComment],
/// The expected number of empty lines before the trailing comments.
expected: u32,
}
impl Format<PyFormatContext<'_>> for FormatEmptyLinesBeforeTrailingComments<'_> {
fn fmt(&self, f: &mut Formatter<PyFormatContext>) -> FormatResult<()> {
if let Some(comment) = self
.comments
.iter()
.find(|comment| comment.line_position().is_own_line())
{
let actual = lines_before(comment.start(), f.context().source()).saturating_sub(1);
for _ in actual..self.expected {
write!(f, [empty_line()])?;
}
}
Ok(())
}
Ok(Cow::Owned(std::format!("# {}", content.trim_start())))
}

View File

@@ -70,6 +70,20 @@ fn handle_parenthesized_comment<'a>(
comment: DecoratedComment<'a>,
locator: &Locator,
) -> CommentPlacement<'a> {
// As a special-case, ignore comments within f-strings, like:
// ```python
// (
// f'{1}' # comment
// f'{2}'
// )
// ```
// These can't be parenthesized, as they must fall between two string tokens in an implicit
// concatenation. But the expression ranges only include the `1` and `2` above, so we also
// can't lex the contents between them.
if comment.enclosing_node().is_expr_f_string() {
return CommentPlacement::Default(comment);
}
let Some(preceding) = comment.preceding_node() else {
return CommentPlacement::Default(comment);
};
@@ -425,7 +439,7 @@ fn handle_own_line_comment_around_body<'a>(
return CommentPlacement::Default(comment);
};
// If there's any non-trivia token between the preceding node and the comment, than it means
// If there's any non-trivia token between the preceding node and the comment, then it means
// we're past the case of the alternate branch, defer to the default rules
// ```python
// if a:
@@ -446,11 +460,78 @@ fn handle_own_line_comment_around_body<'a>(
}
// Check if we're between bodies and should attach to the following body.
handle_own_line_comment_between_branches(comment, preceding, locator).or_else(|comment| {
// Otherwise, there's no following branch or the indentation is too deep, so attach to the
// recursively last statement in the preceding body with the matching indentation.
handle_own_line_comment_after_branch(comment, preceding, locator)
})
handle_own_line_comment_between_branches(comment, preceding, locator)
.or_else(|comment| {
// Otherwise, there's no following branch or the indentation is too deep, so attach to the
// recursively last statement in the preceding body with the matching indentation.
handle_own_line_comment_after_branch(comment, preceding, locator)
})
.or_else(|comment| handle_own_line_comment_between_statements(comment, locator))
}
/// Handles own-line comments between statements. If an own-line comment is between two statements,
/// it's treated as a leading comment of the following statement _if_ there are no empty lines
/// separating the comment and the statement; otherwise, it's treated as a trailing comment of the
/// preceding statement.
///
/// For example, this comment would be a trailing comment of `x = 1`:
/// ```python
/// x = 1
/// # comment
///
/// y = 2
/// ```
///
/// However, this comment would be a leading comment of `y = 2`:
/// ```python
/// x = 1
///
/// # comment
/// y = 2
/// ```
fn handle_own_line_comment_between_statements<'a>(
comment: DecoratedComment<'a>,
locator: &Locator,
) -> CommentPlacement<'a> {
let Some(preceding) = comment.preceding_node() else {
return CommentPlacement::Default(comment);
};
let Some(following) = comment.following_node() else {
return CommentPlacement::Default(comment);
};
// We're looking for comments between two statements, like:
// ```python
// x = 1
// # comment
// y = 2
// ```
if !preceding.is_statement() || !following.is_statement() {
return CommentPlacement::Default(comment);
}
// If the comment is directly attached to the following statement; make it a leading
// comment:
// ```python
// x = 1
//
// # leading comment
// y = 2
// ```
//
// Otherwise, if there's at least one empty line, make it a trailing comment:
// ```python
// x = 1
// # trailing comment
//
// y = 2
// ```
if max_empty_lines(locator.slice(TextRange::new(comment.end(), following.start()))) == 0 {
CommentPlacement::leading(following, comment)
} else {
CommentPlacement::trailing(preceding, comment)
}
}
/// Handles own line comments between two branches of a node.
@@ -1837,6 +1918,7 @@ fn max_empty_lines(contents: &str) -> u32 {
}
}
max_new_lines = newlines.max(max_new_lines);
max_new_lines.saturating_sub(1)
}

View File

@@ -16,7 +16,13 @@ expression: comments.debug(test_case.source_code)
},
],
"dangling": [],
"trailing": [],
"trailing": [
SourceComment {
text: "# own line comment",
position: OwnLine,
formatted: false,
},
],
},
Node {
kind: StmtIf,
@@ -48,19 +54,4 @@ expression: comments.debug(test_case.source_code)
"dangling": [],
"trailing": [],
},
Node {
kind: StmtExpr,
range: 234..246,
source: `test(10, 20)`,
}: {
"leading": [
SourceComment {
text: "# own line comment",
position: OwnLine,
formatted: false,
},
],
"dangling": [],
"trailing": [],
},
}

View File

@@ -3,6 +3,21 @@ source: crates/ruff_python_formatter/src/comments/mod.rs
expression: comments.debug(test_case.source_code)
---
{
Node {
kind: StmtMatch,
range: 27..550,
source: `match pt:⏎`,
}: {
"leading": [],
"dangling": [],
"trailing": [
SourceComment {
text: "# After match comment",
position: OwnLine,
formatted: false,
},
],
},
Node {
kind: MatchCase,
range: 84..132,
@@ -108,19 +123,4 @@ expression: comments.debug(test_case.source_code)
},
],
},
Node {
kind: StmtExpr,
range: 656..670,
source: `print("other")`,
}: {
"leading": [
SourceComment {
text: "# After match comment",
position: OwnLine,
formatted: false,
},
],
"dangling": [],
"trailing": [],
},
}

View File

@@ -3,7 +3,9 @@ use ruff_python_ast::{Decorator, StmtClassDef};
use ruff_python_trivia::lines_after_ignoring_trivia;
use ruff_text_size::Ranged;
use crate::comments::format::empty_lines_before_trailing_comments;
use crate::comments::{leading_comments, trailing_comments, SourceComment};
use crate::context::NodeLevel;
use crate::prelude::*;
use crate::statement::clause::{clause_body, clause_header, ClauseHeader};
use crate::statement::suite::SuiteKind;
@@ -108,7 +110,33 @@ impl FormatNodeRule<StmtClassDef> for FormatStmtClassDef {
),
clause_body(body, trailing_definition_comments).with_kind(SuiteKind::Class),
]
)?;
// If the class contains trailing comments, insert newlines before them.
// For example, given:
// ```python
// class Class:
// ...
// # comment
// ```
//
// At the top-level, reformat as:
// ```python
// class Class:
// ...
//
//
// # comment
// ```
empty_lines_before_trailing_comments(
comments.trailing(item),
if f.context().node_level() == NodeLevel::TopLevel {
2
} else {
1
},
)
.fmt(f)
}
fn fmt_dangling_comments(

View File

@@ -1,9 +1,11 @@
use crate::comments::format::empty_lines_before_trailing_comments;
use ruff_formatter::write;
use ruff_python_ast::{Parameters, StmtFunctionDef};
use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::Ranged;
use crate::comments::SourceComment;
use crate::context::NodeLevel;
use crate::expression::maybe_parenthesize_expression;
use crate::expression::parentheses::{Parentheses, Parenthesize};
use crate::prelude::*;
@@ -144,7 +146,33 @@ impl FormatNodeRule<StmtFunctionDef> for FormatStmtFunctionDef {
),
clause_body(body, trailing_definition_comments).with_kind(SuiteKind::Function),
]
)?;
// If the function contains trailing comments, insert newlines before them.
// For example, given:
// ```python
// def func():
// ...
// # comment
// ```
//
// At the top-level, reformat as:
// ```python
// def func():
// ...
//
//
// # comment
// ```
empty_lines_before_trailing_comments(
comments.trailing(item),
if f.context().node_level() == NodeLevel::TopLevel {
2
} else {
1
},
)
.fmt(f)
}
fn fmt_dangling_comments(

View File

@@ -2,7 +2,7 @@ use ruff_formatter::{write, FormatOwnedWithRule, FormatRefWithRule, FormatRuleWi
use ruff_python_ast::helpers::is_compound_statement;
use ruff_python_ast::node::AnyNodeRef;
use ruff_python_ast::{self as ast, Constant, Expr, ExprConstant, Stmt, Suite};
use ruff_python_trivia::{lines_after_ignoring_trivia, lines_before};
use ruff_python_trivia::{lines_after, lines_after_ignoring_trivia, lines_before};
use ruff_text_size::{Ranged, TextRange};
use crate::comments::{leading_comments, trailing_comments, Comments};
@@ -143,7 +143,11 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
};
while let Some(following) = iter.next() {
if is_class_or_function_definition(preceding)
// Add empty lines before and after a function or class definition. If the preceding
// node is a function or class, and contains trailing comments, then the statement
// itself will add the requisite empty lines when formatting its comments.
if (is_class_or_function_definition(preceding)
&& !comments.has_trailing_own_line(preceding))
|| is_class_or_function_definition(following)
{
match self.kind {
@@ -191,9 +195,13 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
empty_line().fmt(f)?;
}
}
} else if is_import_definition(preceding) && !is_import_definition(following) {
} else if is_import_definition(preceding)
&& (!is_import_definition(following) || comments.has_leading(following))
{
// Enforce _at least_ one empty line after an import statement (but allow up to
// two at the top-level).
// two at the top-level). In this context, "after an import statement" means that
// that the previous node is an import, and the following node is an import _or_ has
// a leading comment.
match self.kind {
SuiteKind::TopLevel => {
match lines_after_ignoring_trivia(preceding.end(), source) {
@@ -274,16 +282,21 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
// it then counts the lines between the statement and the trailing comment, which is
// always 0. This is why it skips any trailing trivia (trivia that's on the same line)
// and counts the lines after.
lines_after_ignoring_trivia(offset, source)
lines_after(offset, source)
};
let end = comments
.trailing(preceding)
.last()
.map_or(preceding.end(), |comment| comment.slice().end());
match node_level {
NodeLevel::TopLevel => match count_lines(preceding.end()) {
NodeLevel::TopLevel => match count_lines(end) {
0 | 1 => hard_line_break().fmt(f)?,
2 => empty_line().fmt(f)?,
_ => write!(f, [empty_line(), empty_line()])?,
},
NodeLevel::CompoundStatement => match count_lines(preceding.end()) {
NodeLevel::CompoundStatement => match count_lines(end) {
0 | 1 => hard_line_break().fmt(f)?,
_ => empty_line().fmt(f)?,
},

View File

@@ -162,7 +162,7 @@ def f():
```diff
--- Black
+++ Ruff
@@ -1,29 +1,182 @@
@@ -1,29 +1,205 @@
+# This file doesn't use the standard decomposition.
+# Decorator syntax test cases are separated by double # comments.
+# Those before the 'output' comment are valid under the old syntax.
@@ -179,6 +179,7 @@ def f():
+
+##
+
+
+@decorator()
+def f():
+ ...
@@ -186,6 +187,7 @@ def f():
+
+##
+
+
+@decorator(arg)
+def f():
+ ...
@@ -193,6 +195,7 @@ def f():
+
+##
+
+
+@decorator(kwarg=0)
+def f():
+ ...
@@ -200,49 +203,50 @@ def f():
+
+##
+
+
+@decorator(*args)
+def f():
+ ...
+
+
##
-@decorator()()
+##
+
+
+@decorator(**kwargs)
def f():
...
+def f():
+ ...
+
+
+##
+
+
##
-@(decorator)
+@decorator(*args, **kwargs)
def f():
...
+def f():
+ ...
+
+
+##
+
+
##
-@sequence["decorator"]
+@decorator(
+ *args,
+ **kwargs,
+)
def f():
...
+def f():
+ ...
+
+
+##
+
+
##
-@decorator[List[str]]
+@dotted.decorator
def f():
...
+def f():
+ ...
+
+
+##
+
+
##
-@var := decorator
+@dotted.decorator(arg)
+def f():
+ ...
@@ -250,43 +254,54 @@ def f():
+
+##
+
+
+@dotted.decorator(kwarg=0)
+def f():
+ ...
+
+
+##
##
-@decorator()()
+
+@dotted.decorator(*args)
+def f():
+ ...
def f():
...
+
+
+##
##
-@(decorator)
+
+@dotted.decorator(**kwargs)
+def f():
+ ...
def f():
...
+
+
+##
##
-@sequence["decorator"]
+
+@dotted.decorator(*args, **kwargs)
+def f():
+ ...
def f():
...
+
+
+##
##
-@decorator[List[str]]
+
+@dotted.decorator(
+ *args,
+ **kwargs,
+)
+def f():
+ ...
def f():
...
+
+
+##
##
-@var := decorator
+
+@double.dotted.decorator
+def f():
@@ -295,6 +310,7 @@ def f():
+
+##
+
+
+@double.dotted.decorator(arg)
+def f():
+ ...
@@ -302,6 +318,7 @@ def f():
+
+##
+
+
+@double.dotted.decorator(kwarg=0)
+def f():
+ ...
@@ -309,6 +326,7 @@ def f():
+
+##
+
+
+@double.dotted.decorator(*args)
+def f():
+ ...
@@ -316,6 +334,7 @@ def f():
+
+##
+
+
+@double.dotted.decorator(**kwargs)
+def f():
+ ...
@@ -323,6 +342,7 @@ def f():
+
+##
+
+
+@double.dotted.decorator(*args, **kwargs)
+def f():
+ ...
@@ -330,6 +350,7 @@ def f():
+
+##
+
+
+@double.dotted.decorator(
+ *args,
+ **kwargs,
@@ -340,6 +361,7 @@ def f():
+
+##
+
+
+@_(sequence["decorator"])
+def f():
+ ...
@@ -347,6 +369,7 @@ def f():
+
+##
+
+
+@eval("sequence['decorator']")
def f():
...
@@ -371,6 +394,7 @@ def f():
##
@decorator()
def f():
...
@@ -378,6 +402,7 @@ def f():
##
@decorator(arg)
def f():
...
@@ -385,6 +410,7 @@ def f():
##
@decorator(kwarg=0)
def f():
...
@@ -392,6 +418,7 @@ def f():
##
@decorator(*args)
def f():
...
@@ -399,6 +426,7 @@ def f():
##
@decorator(**kwargs)
def f():
...
@@ -406,6 +434,7 @@ def f():
##
@decorator(*args, **kwargs)
def f():
...
@@ -413,6 +442,7 @@ def f():
##
@decorator(
*args,
**kwargs,
@@ -423,6 +453,7 @@ def f():
##
@dotted.decorator
def f():
...
@@ -430,6 +461,7 @@ def f():
##
@dotted.decorator(arg)
def f():
...
@@ -437,6 +469,7 @@ def f():
##
@dotted.decorator(kwarg=0)
def f():
...
@@ -444,6 +477,7 @@ def f():
##
@dotted.decorator(*args)
def f():
...
@@ -451,6 +485,7 @@ def f():
##
@dotted.decorator(**kwargs)
def f():
...
@@ -458,6 +493,7 @@ def f():
##
@dotted.decorator(*args, **kwargs)
def f():
...
@@ -465,6 +501,7 @@ def f():
##
@dotted.decorator(
*args,
**kwargs,
@@ -475,6 +512,7 @@ def f():
##
@double.dotted.decorator
def f():
...
@@ -482,6 +520,7 @@ def f():
##
@double.dotted.decorator(arg)
def f():
...
@@ -489,6 +528,7 @@ def f():
##
@double.dotted.decorator(kwarg=0)
def f():
...
@@ -496,6 +536,7 @@ def f():
##
@double.dotted.decorator(*args)
def f():
...
@@ -503,6 +544,7 @@ def f():
##
@double.dotted.decorator(**kwargs)
def f():
...
@@ -510,6 +552,7 @@ def f():
##
@double.dotted.decorator(*args, **kwargs)
def f():
...
@@ -517,6 +560,7 @@ def f():
##
@double.dotted.decorator(
*args,
**kwargs,
@@ -527,6 +571,7 @@ def f():
##
@_(sequence["decorator"])
def f():
...
@@ -534,6 +579,7 @@ def f():
##
@eval("sequence['decorator']")
def f():
...

View File

@@ -156,7 +156,7 @@ aaaaaaaaaaaaa, bbbbbbbbb = map(list, map(itertools.chain.from_iterable, zip(*ite
)
@@ -108,11 +112,20 @@
@@ -108,11 +112,18 @@
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
)
@@ -176,10 +176,7 @@ aaaaaaaaaaaaa, bbbbbbbbb = map(list, map(itertools.chain.from_iterable, zip(*ite
+ ], # type: ignore
)
-aaaaaaaaaaaaa, bbbbbbbbb = map(list, map(itertools.chain.from_iterable, zip(*items))) # type: ignore[arg-type]
+aaaaaaaaaaaaa, bbbbbbbbb = map(
+ list, map(itertools.chain.from_iterable, zip(*items))
+) # type: ignore[arg-type]
aaaaaaaaaaaaa, bbbbbbbbb = map(list, map(itertools.chain.from_iterable, zip(*items))) # type: ignore[arg-type]
```
## Ruff Output
@@ -313,9 +310,7 @@ call_to_some_function_asdf(
], # type: ignore
)
aaaaaaaaaaaaa, bbbbbbbbb = map(
list, map(itertools.chain.from_iterable, zip(*items))
) # type: ignore[arg-type]
aaaaaaaaaaaaa, bbbbbbbbb = map(list, map(itertools.chain.from_iterable, zip(*items))) # type: ignore[arg-type]
```
## Black Output

View File

@@ -1,304 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/simple_cases/empty_lines.py
---
## Input
```py
"""Docstring."""
# leading comment
def f():
NO = ''
SPACE = ' '
DOUBLESPACE = ' '
t = leaf.type
p = leaf.parent # trailing comment
v = leaf.value
if t in ALWAYS_NO_SPACE:
pass
if t == token.COMMENT: # another trailing comment
return DOUBLESPACE
assert p is not None, f"INTERNAL ERROR: hand-made leaf without parent: {leaf!r}"
prev = leaf.prev_sibling
if not prev:
prevp = preceding_leaf(p)
if not prevp or prevp.type in OPENING_BRACKETS:
return NO
if prevp.type == token.EQUAL:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.argument,
}:
return NO
elif prevp.type == token.DOUBLESTAR:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.dictsetmaker,
}:
return NO
###############################################################################
# SECTION BECAUSE SECTIONS
###############################################################################
def g():
NO = ''
SPACE = ' '
DOUBLESPACE = ' '
t = leaf.type
p = leaf.parent
v = leaf.value
# Comment because comments
if t in ALWAYS_NO_SPACE:
pass
if t == token.COMMENT:
return DOUBLESPACE
# Another comment because more comments
assert p is not None, f'INTERNAL ERROR: hand-made leaf without parent: {leaf!r}'
prev = leaf.prev_sibling
if not prev:
prevp = preceding_leaf(p)
if not prevp or prevp.type in OPENING_BRACKETS:
# Start of the line or a bracketed expression.
# More than one line for the comment.
return NO
if prevp.type == token.EQUAL:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.argument,
}:
return NO
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -49,7 +49,6 @@
# SECTION BECAUSE SECTIONS
###############################################################################
-
def g():
NO = ""
SPACE = " "
```
## Ruff Output
```py
"""Docstring."""
# leading comment
def f():
NO = ""
SPACE = " "
DOUBLESPACE = " "
t = leaf.type
p = leaf.parent # trailing comment
v = leaf.value
if t in ALWAYS_NO_SPACE:
pass
if t == token.COMMENT: # another trailing comment
return DOUBLESPACE
assert p is not None, f"INTERNAL ERROR: hand-made leaf without parent: {leaf!r}"
prev = leaf.prev_sibling
if not prev:
prevp = preceding_leaf(p)
if not prevp or prevp.type in OPENING_BRACKETS:
return NO
if prevp.type == token.EQUAL:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.argument,
}:
return NO
elif prevp.type == token.DOUBLESTAR:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.dictsetmaker,
}:
return NO
###############################################################################
# SECTION BECAUSE SECTIONS
###############################################################################
def g():
NO = ""
SPACE = " "
DOUBLESPACE = " "
t = leaf.type
p = leaf.parent
v = leaf.value
# Comment because comments
if t in ALWAYS_NO_SPACE:
pass
if t == token.COMMENT:
return DOUBLESPACE
# Another comment because more comments
assert p is not None, f"INTERNAL ERROR: hand-made leaf without parent: {leaf!r}"
prev = leaf.prev_sibling
if not prev:
prevp = preceding_leaf(p)
if not prevp or prevp.type in OPENING_BRACKETS:
# Start of the line or a bracketed expression.
# More than one line for the comment.
return NO
if prevp.type == token.EQUAL:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.argument,
}:
return NO
```
## Black Output
```py
"""Docstring."""
# leading comment
def f():
NO = ""
SPACE = " "
DOUBLESPACE = " "
t = leaf.type
p = leaf.parent # trailing comment
v = leaf.value
if t in ALWAYS_NO_SPACE:
pass
if t == token.COMMENT: # another trailing comment
return DOUBLESPACE
assert p is not None, f"INTERNAL ERROR: hand-made leaf without parent: {leaf!r}"
prev = leaf.prev_sibling
if not prev:
prevp = preceding_leaf(p)
if not prevp or prevp.type in OPENING_BRACKETS:
return NO
if prevp.type == token.EQUAL:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.argument,
}:
return NO
elif prevp.type == token.DOUBLESTAR:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.dictsetmaker,
}:
return NO
###############################################################################
# SECTION BECAUSE SECTIONS
###############################################################################
def g():
NO = ""
SPACE = " "
DOUBLESPACE = " "
t = leaf.type
p = leaf.parent
v = leaf.value
# Comment because comments
if t in ALWAYS_NO_SPACE:
pass
if t == token.COMMENT:
return DOUBLESPACE
# Another comment because more comments
assert p is not None, f"INTERNAL ERROR: hand-made leaf without parent: {leaf!r}"
prev = leaf.prev_sibling
if not prev:
prevp = preceding_leaf(p)
if not prevp or prevp.type in OPENING_BRACKETS:
# Start of the line or a bracketed expression.
# More than one line for the comment.
return NO
if prevp.type == token.EQUAL:
if prevp.parent and prevp.parent.type in {
syms.typedargslist,
syms.varargslist,
syms.parameters,
syms.arglist,
syms.argument,
}:
return NO
```

View File

@@ -300,17 +300,6 @@ last_call()
) # note: no trailing comma pre-3.6
call(*gidgets[:2])
call(a, *gidgets[:2])
@@ -142,7 +143,9 @@
xxxx_xxxxx_xxxx_xxx: Callable[..., List[SomeClass]] = classmethod( # type: ignore
sync(async_xxxx_xxx_xxxx_xxxxx_xxxx_xxx.__func__)
)
-xxxx_xxx_xxxx_xxxxx_xxxx_xxx: Callable[..., List[SomeClass]] = classmethod( # type: ignore
+xxxx_xxx_xxxx_xxxxx_xxxx_xxx: Callable[
+ ..., List[SomeClass]
+] = classmethod( # type: ignore
sync(async_xxxx_xxx_xxxx_xxxxx_xxxx_xxx.__func__)
)
xxxx_xxx_xxxx_xxxxx_xxxx_xxx: Callable[..., List[SomeClass]] = classmethod(
```
## Ruff Output
@@ -461,9 +450,7 @@ very_long_variable_name_filters: t.List[
xxxx_xxxxx_xxxx_xxx: Callable[..., List[SomeClass]] = classmethod( # type: ignore
sync(async_xxxx_xxx_xxxx_xxxxx_xxxx_xxx.__func__)
)
xxxx_xxx_xxxx_xxxxx_xxxx_xxx: Callable[
..., List[SomeClass]
] = classmethod( # type: ignore
xxxx_xxx_xxxx_xxxxx_xxxx_xxx: Callable[..., List[SomeClass]] = classmethod( # type: ignore
sync(async_xxxx_xxx_xxxx_xxxxx_xxxx_xxx.__func__)
)
xxxx_xxx_xxxx_xxxxx_xxxx_xxx: Callable[..., List[SomeClass]] = classmethod(

View File

@@ -198,7 +198,15 @@ d={'a':1,
```diff
--- Black
+++ Ruff
@@ -63,15 +63,15 @@
@@ -5,6 +5,7 @@
from third_party import X, Y, Z
from library import some_connection, some_decorator
+
# fmt: off
from third_party import (X,
Y, Z)
@@ -63,15 +64,15 @@
something = {
# fmt: off
@@ -217,7 +225,7 @@ d={'a':1,
# fmt: on
goes + here,
andhere,
@@ -122,8 +122,10 @@
@@ -122,8 +123,10 @@
"""
# fmt: off
@@ -229,7 +237,7 @@ d={'a':1,
# fmt: on
pass
@@ -138,7 +140,7 @@
@@ -138,7 +141,7 @@
now . considers . multiple . fmt . directives . within . one . prefix
# fmt: on
# fmt: off
@@ -238,7 +246,7 @@ d={'a':1,
# fmt: on
@@ -178,14 +180,18 @@
@@ -178,14 +181,18 @@
$
""",
# fmt: off
@@ -271,6 +279,7 @@ import sys
from third_party import X, Y, Z
from library import some_connection, some_decorator
# fmt: off
from third_party import (X,
Y, Z)

View File

@@ -110,15 +110,7 @@ elif unformatted:
},
)
@@ -74,7 +73,6 @@
class Factory(t.Protocol):
def this_will_be_formatted(self, **kwargs) -> Named:
...
-
# fmt: on
@@ -82,6 +80,6 @@
@@ -82,6 +81,6 @@
if x:
return x
# fmt: off
@@ -206,6 +198,7 @@ class Named(t.Protocol):
class Factory(t.Protocol):
def this_will_be_formatted(self, **kwargs) -> Named:
...
# fmt: on

View File

@@ -0,0 +1,93 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/simple_cases/fmtpass_imports.py
---
## Input
```py
# Regression test for https://github.com/psf/black/issues/3438
import ast
import collections # fmt: skip
import dataclasses
# fmt: off
import os
# fmt: on
import pathlib
import re # fmt: skip
import secrets
# fmt: off
import sys
# fmt: on
import tempfile
import zoneinfo
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -3,6 +3,7 @@
import ast
import collections # fmt: skip
import dataclasses
+
# fmt: off
import os
# fmt: on
```
## Ruff Output
```py
# Regression test for https://github.com/psf/black/issues/3438
import ast
import collections # fmt: skip
import dataclasses
# fmt: off
import os
# fmt: on
import pathlib
import re # fmt: skip
import secrets
# fmt: off
import sys
# fmt: on
import tempfile
import zoneinfo
```
## Black Output
```py
# Regression test for https://github.com/psf/black/issues/3438
import ast
import collections # fmt: skip
import dataclasses
# fmt: off
import os
# fmt: on
import pathlib
import re # fmt: skip
import secrets
# fmt: off
import sys
# fmt: on
import tempfile
import zoneinfo
```

View File

@@ -1,232 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/simple_cases/power_op_spacing.py
---
## Input
```py
def function(**kwargs):
t = a**2 + b**3
return t ** 2
def function_replace_spaces(**kwargs):
t = a **2 + b** 3 + c ** 4
def function_dont_replace_spaces():
{**a, **b, **c}
a = 5**~4
b = 5 ** f()
c = -(5**2)
d = 5 ** f["hi"]
e = lazy(lambda **kwargs: 5)
f = f() ** 5
g = a.b**c.d
h = 5 ** funcs.f()
i = funcs.f() ** 5
j = super().name ** 5
k = [(2**idx, value) for idx, value in pairs]
l = mod.weights_[0] == pytest.approx(0.95**100, abs=0.001)
m = [([2**63], [1, 2**63])]
n = count <= 10**5
o = settings(max_examples=10**6)
p = {(k, k**2): v**2 for k, v in pairs}
q = [10**i for i in range(6)]
r = x**y
a = 5.0**~4.0
b = 5.0 ** f()
c = -(5.0**2.0)
d = 5.0 ** f["hi"]
e = lazy(lambda **kwargs: 5)
f = f() ** 5.0
g = a.b**c.d
h = 5.0 ** funcs.f()
i = funcs.f() ** 5.0
j = super().name ** 5.0
k = [(2.0**idx, value) for idx, value in pairs]
l = mod.weights_[0] == pytest.approx(0.95**100, abs=0.001)
m = [([2.0**63.0], [1.0, 2**63.0])]
n = count <= 10**5.0
o = settings(max_examples=10**6.0)
p = {(k, k**2): v**2.0 for k, v in pairs}
q = [10.5**i for i in range(6)]
# WE SHOULD DEFINITELY NOT EAT THESE COMMENTS (https://github.com/psf/black/issues/2873)
if hasattr(view, "sum_of_weights"):
return np.divide( # type: ignore[no-any-return]
view.variance, # type: ignore[union-attr]
view.sum_of_weights, # type: ignore[union-attr]
out=np.full(view.sum_of_weights.shape, np.nan), # type: ignore[union-attr]
where=view.sum_of_weights**2 > view.sum_of_weights_squared, # type: ignore[union-attr]
)
return np.divide(
where=view.sum_of_weights_of_weight_long**2 > view.sum_of_weights_squared, # type: ignore
)
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -55,9 +55,11 @@
view.variance, # type: ignore[union-attr]
view.sum_of_weights, # type: ignore[union-attr]
out=np.full(view.sum_of_weights.shape, np.nan), # type: ignore[union-attr]
- where=view.sum_of_weights**2 > view.sum_of_weights_squared, # type: ignore[union-attr]
+ where=view.sum_of_weights**2
+ > view.sum_of_weights_squared, # type: ignore[union-attr]
)
return np.divide(
- where=view.sum_of_weights_of_weight_long**2 > view.sum_of_weights_squared, # type: ignore
+ where=view.sum_of_weights_of_weight_long**2
+ > view.sum_of_weights_squared, # type: ignore
)
```
## Ruff Output
```py
def function(**kwargs):
t = a**2 + b**3
return t**2
def function_replace_spaces(**kwargs):
t = a**2 + b**3 + c**4
def function_dont_replace_spaces():
{**a, **b, **c}
a = 5**~4
b = 5 ** f()
c = -(5**2)
d = 5 ** f["hi"]
e = lazy(lambda **kwargs: 5)
f = f() ** 5
g = a.b**c.d
h = 5 ** funcs.f()
i = funcs.f() ** 5
j = super().name ** 5
k = [(2**idx, value) for idx, value in pairs]
l = mod.weights_[0] == pytest.approx(0.95**100, abs=0.001)
m = [([2**63], [1, 2**63])]
n = count <= 10**5
o = settings(max_examples=10**6)
p = {(k, k**2): v**2 for k, v in pairs}
q = [10**i for i in range(6)]
r = x**y
a = 5.0**~4.0
b = 5.0 ** f()
c = -(5.0**2.0)
d = 5.0 ** f["hi"]
e = lazy(lambda **kwargs: 5)
f = f() ** 5.0
g = a.b**c.d
h = 5.0 ** funcs.f()
i = funcs.f() ** 5.0
j = super().name ** 5.0
k = [(2.0**idx, value) for idx, value in pairs]
l = mod.weights_[0] == pytest.approx(0.95**100, abs=0.001)
m = [([2.0**63.0], [1.0, 2**63.0])]
n = count <= 10**5.0
o = settings(max_examples=10**6.0)
p = {(k, k**2): v**2.0 for k, v in pairs}
q = [10.5**i for i in range(6)]
# WE SHOULD DEFINITELY NOT EAT THESE COMMENTS (https://github.com/psf/black/issues/2873)
if hasattr(view, "sum_of_weights"):
return np.divide( # type: ignore[no-any-return]
view.variance, # type: ignore[union-attr]
view.sum_of_weights, # type: ignore[union-attr]
out=np.full(view.sum_of_weights.shape, np.nan), # type: ignore[union-attr]
where=view.sum_of_weights**2
> view.sum_of_weights_squared, # type: ignore[union-attr]
)
return np.divide(
where=view.sum_of_weights_of_weight_long**2
> view.sum_of_weights_squared, # type: ignore
)
```
## Black Output
```py
def function(**kwargs):
t = a**2 + b**3
return t**2
def function_replace_spaces(**kwargs):
t = a**2 + b**3 + c**4
def function_dont_replace_spaces():
{**a, **b, **c}
a = 5**~4
b = 5 ** f()
c = -(5**2)
d = 5 ** f["hi"]
e = lazy(lambda **kwargs: 5)
f = f() ** 5
g = a.b**c.d
h = 5 ** funcs.f()
i = funcs.f() ** 5
j = super().name ** 5
k = [(2**idx, value) for idx, value in pairs]
l = mod.weights_[0] == pytest.approx(0.95**100, abs=0.001)
m = [([2**63], [1, 2**63])]
n = count <= 10**5
o = settings(max_examples=10**6)
p = {(k, k**2): v**2 for k, v in pairs}
q = [10**i for i in range(6)]
r = x**y
a = 5.0**~4.0
b = 5.0 ** f()
c = -(5.0**2.0)
d = 5.0 ** f["hi"]
e = lazy(lambda **kwargs: 5)
f = f() ** 5.0
g = a.b**c.d
h = 5.0 ** funcs.f()
i = funcs.f() ** 5.0
j = super().name ** 5.0
k = [(2.0**idx, value) for idx, value in pairs]
l = mod.weights_[0] == pytest.approx(0.95**100, abs=0.001)
m = [([2.0**63.0], [1.0, 2**63.0])]
n = count <= 10**5.0
o = settings(max_examples=10**6.0)
p = {(k, k**2): v**2.0 for k, v in pairs}
q = [10.5**i for i in range(6)]
# WE SHOULD DEFINITELY NOT EAT THESE COMMENTS (https://github.com/psf/black/issues/2873)
if hasattr(view, "sum_of_weights"):
return np.divide( # type: ignore[no-any-return]
view.variance, # type: ignore[union-attr]
view.sum_of_weights, # type: ignore[union-attr]
out=np.full(view.sum_of_weights.shape, np.nan), # type: ignore[union-attr]
where=view.sum_of_weights**2 > view.sum_of_weights_squared, # type: ignore[union-attr]
)
return np.divide(
where=view.sum_of_weights_of_weight_long**2 > view.sum_of_weights_squared, # type: ignore
)
```

View File

@@ -50,17 +50,14 @@ assert (
) #
assert sort_by_dependency(
@@ -25,9 +25,11 @@
@@ -25,9 +25,9 @@
class A:
def foo(self):
for _ in range(10):
- aaaaaaaaaaaaaaaaaaa = bbbbbbbbbbbbbbb.cccccccccc(
- xxxxxxxxxxxx
+ aaaaaaaaaaaaaaaaaaa = bbbbbbbbbbbbbbb.cccccccccc( # pylint: disable=no-member
xxxxxxxxxxxx
- ) # pylint: disable=no-member
+ aaaaaaaaaaaaaaaaaaa = (
+ bbbbbbbbbbbbbbb.cccccccccc( # pylint: disable=no-member
+ xxxxxxxxxxxx
+ )
+ )
@@ -97,10 +94,8 @@ importA
class A:
def foo(self):
for _ in range(10):
aaaaaaaaaaaaaaaaaaa = (
bbbbbbbbbbbbbbb.cccccccccc( # pylint: disable=no-member
xxxxxxxxxxxx
)
aaaaaaaaaaaaaaaaaaa = bbbbbbbbbbbbbbb.cccccccccc( # pylint: disable=no-member
xxxxxxxxxxxx
)

View File

@@ -39,6 +39,30 @@ result_f = (
# comment
''
)
(
f'{1}' # comment
f'{2}'
)
(
f'{1}'
f'{2}' # comment
)
(
1, ( # comment
f'{2}'
)
)
(
(
f'{1}'
# comment
),
2
)
```
## Output
@@ -76,6 +100,30 @@ result_f = (
# comment
""
)
(
f"{1}" # comment
f"{2}"
)
(
f"{1}" f"{2}" # comment
)
(
1,
( # comment
f"{2}"
),
)
(
(
f"{1}"
# comment
),
2,
)
```

View File

@@ -4,61 +4,6 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off
---
## Input
```py
def test():
# fmt: off
a_very_small_indent
(
not_fixed
)
if True:
pass
more
# fmt: on
formatted
def test():
a_small_indent
# fmt: off
# fix under-indented comments
(or_the_inner_expression +
expressions
)
if True:
pass
# fmt: on
# fmt: off
def test():
pass
# It is necessary to indent comments because the following fmt: on comment because it otherwise becomes a trailing comment
# of the `test` function if the "proper" indentation is larger than 2 spaces.
# fmt: on
disabled + formatting;
# fmt: on
formatted;
def test():
pass
# fmt: off
"""A multiline strings
that should not get formatted"""
"A single quoted multiline \
string"
disabled + formatting;
# fmt: on
formatted;
```
## Outputs
@@ -72,63 +17,6 @@ magic-trailing-comma = Respect
```
```py
def test():
# fmt: off
a_very_small_indent
(
not_fixed
)
if True:
pass
more
# fmt: on
formatted
def test():
a_small_indent
# fmt: off
# fix under-indented comments
(or_the_inner_expression +
expressions
)
if True:
pass
# fmt: on
# fmt: off
def test():
pass
# It is necessary to indent comments because the following fmt: on comment because it otherwise becomes a trailing comment
# of the `test` function if the "proper" indentation is larger than 2 spaces.
# fmt: on
disabled + formatting;
# fmt: on
formatted
def test():
pass
# fmt: off
"""A multiline strings
that should not get formatted"""
"A single quoted multiline \
string"
disabled + formatting
# fmt: on
formatted
```
@@ -142,63 +30,6 @@ magic-trailing-comma = Respect
```
```py
def test():
# fmt: off
a_very_small_indent
(
not_fixed
)
if True:
pass
more
# fmt: on
formatted
def test():
a_small_indent
# fmt: off
# fix under-indented comments
(or_the_inner_expression +
expressions
)
if True:
pass
# fmt: on
# fmt: off
def test():
pass
# It is necessary to indent comments because the following fmt: on comment because it otherwise becomes a trailing comment
# of the `test` function if the "proper" indentation is larger than 2 spaces.
# fmt: on
disabled + formatting;
# fmt: on
formatted
def test():
pass
# fmt: off
"""A multiline strings
that should not get formatted"""
"A single quoted multiline \
string"
disabled + formatting
# fmt: on
formatted
```
@@ -212,63 +43,6 @@ magic-trailing-comma = Respect
```
```py
def test():
# fmt: off
a_very_small_indent
(
not_fixed
)
if True:
pass
more
# fmt: on
formatted
def test():
a_small_indent
# fmt: off
# fix under-indented comments
(or_the_inner_expression +
expressions
)
if True:
pass
# fmt: on
# fmt: off
def test():
pass
# It is necessary to indent comments because the following fmt: on comment because it otherwise becomes a trailing comment
# of the `test` function if the "proper" indentation is larger than 2 spaces.
# fmt: on
disabled + formatting;
# fmt: on
formatted
def test():
pass
# fmt: off
"""A multiline strings
that should not get formatted"""
"A single quoted multiline \
string"
disabled + formatting
# fmt: on
formatted
```

View File

@@ -45,6 +45,8 @@ not_fixed
more
else:
other
# fmt: on
```
@@ -72,6 +74,8 @@ not_fixed
more
else:
other
# fmt: on
```
@@ -99,6 +103,8 @@ not_fixed
more
else:
other
# fmt: on
```

View File

@@ -0,0 +1,90 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/newlines.py
---
## Input
```py
def func():
pass
# fmt: off
x = 1
# fmt: on
# fmt: off
def func():
pass
# fmt: on
x = 1
# fmt: off
def func():
pass
# fmt: on
def func():
pass
# fmt: off
def func():
pass
# fmt: off
def func():
pass
# fmt: on
def func():
pass
# fmt: on
def func():
pass
```
## Output
```py
def func():
pass
# fmt: off
x = 1
# fmt: on
# fmt: off
def func():
pass
# fmt: on
x = 1
# fmt: off
def func():
pass
# fmt: on
def func():
pass
# fmt: off
def func():
pass
# fmt: off
def func():
pass
# fmt: on
def func():
pass
# fmt: on
def func():
pass
```

View File

@@ -0,0 +1,345 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/newlines.py
---
## Input
```py
###
# Blank lines around functions
###
x = 1
# comment
def f():
pass
if True:
x = 1
# comment
def f():
pass
x = 1
# comment
def f():
pass
x = 1
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
# comment
def f():
pass
# comment
def f():
pass
# comment
###
# Blank lines around imports.
###
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x # comment
# comment
import y
def f(): pass # comment
# comment
x = 1
def f():
pass
# comment
x = 1
```
## Output
```py
###
# Blank lines around functions
###
x = 1
# comment
def f():
pass
if True:
x = 1
# comment
def f():
pass
x = 1
# comment
def f():
pass
x = 1
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
x = 1
# comment
# comment
def f():
pass
# comment
def f():
pass
# comment
def f():
pass
# comment
###
# Blank lines around imports.
###
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x
# comment
import y
def f():
import x # comment
# comment
import y
def f():
pass # comment
# comment
x = 1
def f():
pass
# comment
x = 1
```

View File

@@ -191,10 +191,9 @@ assert (
# Trailing test value own-line
# Test dangler
), "Some string" # Trailing msg same-line
# Trailing assert
def test():
assert (
{

View File

@@ -406,6 +406,7 @@ def test(
### Different function argument wrappings
def single_line(aaaaaaaaaaaaaaaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbb, ccccccccccccccccc):
pass
@@ -511,6 +512,7 @@ def type_param_comments[ # trailing bracket comment
# Different type parameter wrappings
def single_line[Aaaaaaaaaaaaaaaaaaaaaaaaaaaaa, Bbbbbbbbbbbbbbb, Ccccccccccccccccc]():
pass

View File

@@ -4,22 +4,72 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/trailing_c
---
## Input
```py
# Pragma reserved width fixtures
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # type: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pyright: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pylint: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # nocoverage: This should break
# Pragma fixtures for non-breaking space (lead by NBSP)
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # type: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pyright: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pylint: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # nocoverage: This should break
# As of adding this fixture Black adds a space before the non-breaking space if part of a type pragma.
# https://github.com/psf/black/blob/b4dca26c7d93f930bbd5a7b552807370b60d4298/src/black/comments.py#L122-L129
i2 = "" #  type: Add space before leading NBSP followed by spaces
i3 = "" #type: A space is added
i4 = "" #  type: Add space before leading NBSP followed by a space
i5 = "" # type: Add space before leading NBSP
i = "" #  type: Add space before leading NBSP followed by spaces
i = "" #type: A space is added
i = "" #  type: Add space before leading NBSP followed by a space
i = "" # type: Add space before leading NBSP
i = "" #  type: Add space before two leading NBSP
# A noqa as `#\u{A0}\u{A0}noqa` becomes `# \u{A0}noqa`
i = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" #  noqa
```
## Output
```py
# Pragma reserved width fixtures
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # type: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pyright: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pylint: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa This shouldn't break
i = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
) # nocoverage: This should break
# Pragma fixtures for non-breaking space (lead by NBSP)
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) #  type: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pyright: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # pylint: This shouldn't break
i = ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",) # noqa This shouldn't break
i = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
) # nocoverage: This should break
# As of adding this fixture Black adds a space before the non-breaking space if part of a type pragma.
# https://github.com/psf/black/blob/b4dca26c7d93f930bbd5a7b552807370b60d4298/src/black/comments.py#L122-L129
i2 = "" #   type: Add space before leading NBSP followed by spaces
i3 = "" # type: A space is added
i4 = "" #   type: Add space before leading NBSP followed by a space
i5 = "" #  type: Add space before leading NBSP
i = "" #   type: Add space before leading NBSP followed by spaces
i = "" # type: A space is added
i = "" #   type: Add space before leading NBSP followed by a space
i = "" #  type: Add space before leading NBSP
i = "" #   type: Add space before two leading NBSP
# A noqa as `#\u{A0}\u{A0}noqa` becomes `# \u{A0}noqa`
i = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" #  noqa
```

View File

@@ -643,6 +643,30 @@ with (0 as a, 1 as b,): pass
insta::assert_debug_snapshot!(parse_suite(source, "<test>").unwrap());
}
#[test]
fn test_parenthesized_with_statement() {
let source = "\
with ((a), (b)): pass
with ((a), (b), c as d, (e)): pass
with (a, b): pass
with (a, b) as c: pass
with ((a, b) as c): pass
with (a as b): pass
with (a): pass
with (a := 0): pass
with (a := 0) as x: pass
with ((a)): pass
with ((a := 0)): pass
with (a as b, (a := 0)): pass
with (a, (a := 0)): pass
with (yield): pass
with (yield from a): pass
with ((yield)): pass
with ((yield from a)): pass
";
insta::assert_debug_snapshot!(parse_suite(source, "<test>").unwrap());
}
#[test]
fn test_with_statement_invalid() {
for source in [

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More