diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index ec9156fa191..cca808f6788 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -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[] diff --git a/docs/modules/ROOT/pages/pythonlib/linting.adoc b/docs/modules/ROOT/pages/pythonlib/linting.adoc new file mode 100644 index 00000000000..4b02ed5f5e9 --- /dev/null +++ b/docs/modules/ROOT/pages/pythonlib/linting.adoc @@ -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[] + diff --git a/example/package.mill b/example/package.mill index ea564d19c17..4e77ef5d8ea 100644 --- a/example/package.mill +++ b/example/package.mill @@ -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")) diff --git a/example/pythonlib/linting/1-ruff-format/build.mill b/example/pythonlib/linting/1-ruff-format/build.mill new file mode 100644 index 00000000000..c612028b349 --- /dev/null +++ b/example/pythonlib/linting/1-ruff-format/build.mill @@ -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 + +> 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 +*/ diff --git a/example/pythonlib/linting/1-ruff-format/src/main.py b/example/pythonlib/linting/1-ruff-format/src/main.py new file mode 100644 index 00000000000..553a9131ae2 --- /dev/null +++ b/example/pythonlib/linting/1-ruff-format/src/main.py @@ -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) diff --git a/example/pythonlib/linting/2-ruff-check/build.mill b/example/pythonlib/linting/2-ruff-check/build.mill new file mode 100644 index 00000000000..f1bac956791 --- /dev/null +++ b/example/pythonlib/linting/2-ruff-check/build.mill @@ -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("") + +*/ diff --git a/example/pythonlib/linting/2-ruff-check/src/main.py b/example/pythonlib/linting/2-ruff-check/src/main.py new file mode 100644 index 00000000000..90244c928ad --- /dev/null +++ b/example/pythonlib/linting/2-ruff-check/src/main.py @@ -0,0 +1,4 @@ +import os + +def doit(x: int): + print(f"") diff --git a/pythonlib/src/mill/pythonlib/PythonModule.scala b/pythonlib/src/mill/pythonlib/PythonModule.scala index 3dae6712069..94182a138fe 100644 --- a/pythonlib/src/mill/pythonlib/PythonModule.scala +++ b/pythonlib/src/mill/pythonlib/PythonModule.scala @@ -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 ) } diff --git a/pythonlib/src/mill/pythonlib/RuffModule.scala b/pythonlib/src/mill/pythonlib/RuffModule.scala new file mode 100644 index 00000000000..79ea6b15cd0 --- /dev/null +++ b/pythonlib/src/mill/pythonlib/RuffModule.scala @@ -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] + +} diff --git a/pythonlib/src/mill/pythonlib/TestModule.scala b/pythonlib/src/mill/pythonlib/TestModule.scala index 4769df2bd83..f317789ca40 100644 --- a/pythonlib/src/mill/pythonlib/TestModule.scala +++ b/pythonlib/src/mill/pythonlib/TestModule.scala @@ -56,7 +56,8 @@ object TestModule { args() } runner().run( - ("-m", "unittest", testArgs, "-v") + ("-m", "unittest", testArgs, "-v"), + workingDir = Task.workspace ) Seq() } @@ -78,7 +79,8 @@ object TestModule { sources().map(_.path), args() // format: in - ) + ), + workingDir = Task.workspace ) Seq() }