Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pythonlib: add linting with ruff #4072

Merged
merged 17 commits into from
Jan 31, 2025
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* xref:pythonlib/intro.adoc[]
** xref:pythonlib/module-config.adoc[]
** xref:pythonlib/dependencies.adoc[]
** xref:pythonlib/linting.adoc[]
** xref:pythonlib/testing.adoc[]
** xref:pythonlib/publishing.adoc[]
** xref:pythonlib/web-examples.adoc[]
Expand Down
19 changes: 19 additions & 0 deletions docs/modules/ROOT/pages/pythonlib/linting.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
= Linting Python Projects

This page will discuss common topics around maintaining the code quality of Python codebases using
the Mill build tool

== Formatting and Linting with Ruff

https://docs.astral.sh/ruff/[Ruff] is a Python linter and code formatter. Mill has built-in support
for invoking Ruff on your Python projects, to help you catch common sources of errors and keep your
code nice and tidy.

=== Formatting

include::partial$example/pythonlib/linting/1-ruff-format.adoc[]

=== Linting

include::partial$example/pythonlib/linting/2-ruff-check.adoc[]

1 change: 1 addition & 0 deletions example/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ object `package` extends RootModule with Module {
object basic extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "basic"))
object dependencies
extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "dependencies"))
object linting extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "linting"))
object publishing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "publishing"))
object module extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "module"))
object web extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "web"))
Expand Down
99 changes: 99 additions & 0 deletions example/pythonlib/linting/1-ruff-format/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// First, make your module extend `pythonlib.RuffModule`.

package build
import mill._, pythonlib._

object `package` extends RootModule with PythonModule with RuffModule

// You can reformat your project's code by running the `ruffFormat` task.

/** Usage

> cat src/main.py # initial poorly formatted source code
from typing import Self
class IntWrapper:
def __init__(self, x:int):
self.x =x
def plus(self, w:Self) -> Self:
return IntWrapper(self.x + w.x)
print(IntWrapper(2).plus(IntWrapper(3)).x)
...

> mill ruffFormat --diff # you can also pass in extra arguments understood by `ruff format`
error: ...
error: @@ -1,7 +1,12 @@
error: from typing import Self
error: +
error: +
error: class IntWrapper:
error: - def __init__(self, x:int):
error: - self.x =x
error: - def plus(self, w:Self) -> Self:
error: - return IntWrapper(self.x + w.x)
error: + def __init__(self, x: int):
error: + self.x = x
error: +
error: + def plus(self, w: Self) -> Self:
error: + return IntWrapper(self.x + w.x)
error: +
error: +
error: print(IntWrapper(2).plus(IntWrapper(3)).x)
error: ...
error: 1 file would be reformatted

> mill ruffFormat
...1 file reformatted
jodersky marked this conversation as resolved.
Show resolved Hide resolved

> cat src/main.py # the file is now correctly formatted
from typing import Self
...
class IntWrapper:
def __init__(self, x: int):
self.x = x
...
def plus(self, w: Self) -> Self:
return IntWrapper(self.x + w.x)
...
print(IntWrapper(2).plus(IntWrapper(3)).x)
*/

// You can create a `ruff.toml` file in your project root to adjust the
// formatting options as desired. For example,

/** Usage

> echo indent-width=2 > ruff.toml

> mill ruffFormat
...1 file reformatted

> cat src/main.py # the file is now correctly formatted with 2 spaces indentation
from typing import Self
...
class IntWrapper:
def __init__(self, x: int):
self.x = x
...
def plus(self, w: Self) -> Self:
return IntWrapper(self.x + w.x)
...
print(IntWrapper(2).plus(IntWrapper(3)).x)

*/

// Mill also has built-in global tasks, which allow you to run ruff across all projects in your
// build, without ever needing to extend `RuffModule`.
//
// - format all Python files globally: `mill mill.pythonlib.RuffModule/formatAll`
// - lint all Python files globally: `mill mill.pythonlib.RuffModule/checkAll`
//
// You can also pass-in extra arguments to ruff, for example to find unformatted files and show the
// diff: `mill mill.pythonlib.RuffModule/formatAll --diff`
//
// If entering `mill.pythonlib.RuffModule/formatAll` is too long, you can add an
// xref:fundamentals/modules.adoc#_aliasing_external_modules[External Module Alias] to give it a
// shorter name that's easier to type.

/** Usage
> mill mill.pythonlib.RuffModule/formatAll
*/
7 changes: 7 additions & 0 deletions example/pythonlib/linting/1-ruff-format/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from typing import Self
class IntWrapper:
def __init__(self, x:int):
self.x =x
def plus(self, w:Self) -> Self:
return IntWrapper(self.x + w.x)
print(IntWrapper(2).plus(IntWrapper(3)).x)
48 changes: 48 additions & 0 deletions example/pythonlib/linting/2-ruff-check/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package build
import mill._, pythonlib._

object `package` extends RootModule with PythonModule with RuffModule {}

/** See Also: src/main.py */

// Ruff can be used as a linter, to catch some common code smells. Run `ruffCheck` on your module:

/** Usage

> mill ruffCheck
error: ...
error: ...F401 [*] `os` imported but unused
error: |
error: 1 | import os
error: | ^^ F401
error: 2 |
error: 3 | def doit(x: int):
error: |
error: = help: Remove unused import: `os`
error: ...
error: ...F541 [*] f-string without any placeholders
error: |
error: 3 | def doit(x: int):
error: 4 | print(f"")
error: | ^^^ F541
error: |
error: = help: Remove extraneous `f` prefix
error: ...
error: Found 2 errors.
error: [*] 2 fixable with the `--fix` option.

*/

// Ruff can fix most errors automatically with `ruffCheck --fix`

/** Usage

> mill ruffCheck --fix
Found 2 errors (2 fixed, 0 remaining).

> cat src/main.py
...
def doit(x: int):
print("")

*/
4 changes: 4 additions & 0 deletions example/pythonlib/linting/2-ruff-check/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import os

def doit(x: int):
print(f"")
2 changes: 1 addition & 1 deletion pythonlib/src/mill/pythonlib/PythonModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ trait PythonModule extends PipModule with TaskModule { outer =>
command0 = pythonExe().path.toString,
options = pythonOptions(),
env0 = runnerEnvTask() ++ forkEnv(),
workingDir0 = Task.workspace
workingDir0 = Task.dest
)
}

Expand Down
127 changes: 127 additions & 0 deletions pythonlib/src/mill/pythonlib/RuffModule.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package mill.pythonlib

import mill._
import mill.define.{Args, ExternalModule, Discover}
import mill.main.Tasks

/**
* Linting and formatting functionality provided by [ruff](https://docs.astral.sh/ruff/).
*/
trait RuffModule extends PythonModule {

override def pythonToolDeps = Task {
super.pythonToolDeps() ++ Seq("ruff>=0.9.3")
}

/**
* Configuration file to use when running ruff. If this file does not exist,
* ruff will use the default settings.
*/
def ruffConfigFile: T[PathRef] = Task.Source(millSourcePath / "ruff.toml")

/**
* Global command line options to pass to ruff. These are passed in before any
* command-supplied arguments.
*/
def ruffOptions: T[Seq[String]] = Task { Seq.empty[String] }

protected def configArgs: Task[Seq[String]] = Task.Anon {
val cfg = ruffConfigFile()
if (os.exists(cfg.path)) Seq("--config", cfg.path.toString) else Seq.empty[String]
}

/**
* Run `ruff format` on all the source files of this module.
*
* You can supply any additional args that ruff understands. For example:
*
* - only check format of sources, but don't actually format: `--check`
* - see format diff: `--diff`
*/
def ruffFormat(args: String*): Command[Unit] = Task.Command {
runner().run(
// format: off
(
"-m", "ruff",
"format",
configArgs(),
ruffOptions(),
args,
sources().map(_.path)
),
// format: on
workingDir = Task.dest
)
}

/**
* Run `run check` on all the source files of this module.
*
* You can supply additional arguments that ruff understands, for example to
* attempt to automatically fix any linting errors: `--fix`.
*/
def ruffCheck(args: String*): Command[Unit] = Task.Command {
runner().run(
// format: off
(
"-m", "ruff",
"check",
"--cache-dir", T.dest / "cache",
configArgs(),
ruffOptions(),
args,
sources().map(_.path)
),
// format: on
workingDir = Task.dest
)
}

}

object RuffModule extends ExternalModule with RuffModule with TaskModule {

override def defaultCommandName(): String = "formatAll"

def formatAll(
sources: Tasks[Seq[PathRef]] = Tasks.resolveMainDefault("__.sources"),
@mainargs.arg(positional = true) ruffArgs: Args
): Command[Unit] = Task.Command {
runner().run(
// format: off
(
"-m", "ruff",
"format",
configArgs(),
ruffOptions(),
ruffArgs.value,
T.sequence(sources.value)().flatten.map(_.path)
),
// format: on
workingDir = Task.dest
)
}

def checkAll(
sources: Tasks[Seq[PathRef]] = Tasks.resolveMainDefault("__.sources"),
@mainargs.arg(positional = true) ruffArgs: Args
): Command[Unit] = Task.Command {
runner().run(
// format: off
(
"-m", "ruff",
"check",
"--cache-dir", T.dest / "cache",
configArgs(),
ruffOptions(),
ruffArgs.value,
T.sequence(sources.value)().flatten.map(_.path)
),
// format: on
workingDir = Task.dest
)
}

lazy val millDiscover: Discover = Discover[this.type]

}
6 changes: 4 additions & 2 deletions pythonlib/src/mill/pythonlib/TestModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ object TestModule {
args()
}
runner().run(
("-m", "unittest", testArgs, "-v")
("-m", "unittest", testArgs, "-v"),
workingDir = Task.workspace
)
Seq()
}
Expand All @@ -78,7 +79,8 @@ object TestModule {
sources().map(_.path),
args()
// format: in
)
),
workingDir = Task.workspace
)
Seq()
}
Expand Down
Loading