diff --git a/doc/development/testing.md b/doc/development/testing.md index 90ac17e38ae..9fb8bdc2b02 100644 --- a/doc/development/testing.md +++ b/doc/development/testing.md @@ -171,6 +171,48 @@ def test_something_which_changes_the_environment(xy_dataset_session): ... ``` +## Type checking + +The _ty_, _pyrefly_, _Zuban_, _pyright_ and _basedpyright_ type checkers are +configured in `pyproject.toml` without any machine-specific paths. The +`grass.lib` package is generated during the build, so the checkers need to be +pointed to a built or installed GRASS, in one of two ways. + +On the command line and in CI, run the checker in a GRASS session. The `grass` +command sets `PYTHONPATH` to the Python code of the build or installation: + +```bash +grass --tmp-project XY --exec ty check +``` + +Editors such as VS Code start the language server themselves, outside a GRASS +session, so they need the location in a file instead. A virtual environment is +only used as a place to put that file, because type checkers and editors +already read the site-packages directory of the selected interpreter. Nothing +is built or installed into it, and `grass` is still built the usual way. The +script writes one file, `grass_generated.pth`, a text file with the directory +printed by `grass --config python_path`, which Python and the type checkers +treat as an extra import location. + +```bash +python utils/typecheck_env.py --grass build/output/bin/grass +ty check +``` + +This creates `.venv` in the repository, or reuses and adds the file to an +existing environment given with `--venv`, for example the one you already use +for development. Select that environment as the interpreter in the editor. +Without the script, the same can be done by hand by creating the file in the +site-packages directory of the environment. Rerun the script when switching to +another build or installation, because the path is stored in the file. + +The `grass` package is taken from the build or installation, not from +`python/grass`, so rebuild after changing the source to see the change in +imports from other modules. Missing optional dependencies, such as wxPython or +matplotlib, are reported as unresolved imports unless installed in the +environment. The existing results are not clean yet, so these checks are not +enforced. + ## More information - [grass.gunittest documentation](https://grass.osgeo.org/grass-devel/manuals/libpython/gunittest_testing.html) diff --git a/pyproject.toml b/pyproject.toml index 71be308a430..1efefda39e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -899,12 +899,43 @@ additional-builtins = ["_"] exclude = ["**/__pycache__", "**/OBJ.*", "bin.*/**", "dist.*/**"] include = ["python"] -# Set extraPath to the output of $(grass --config python_path) - # *GRASS modified* # *GRASS TODO: keep in sync with MIN_PYTHON_VERSION supported* pythonVersion = "3.11" +# Type checkers find the generated code (grass.lib, in the build or install +# tree) from outside the repository, so no machine-specific paths are needed +# here. See the "Type checking" section in doc/development/testing.md. +venvPath = "." +venv = ".venv" + +[tool.ty.environment] +# *GRASS TODO: keep in sync with MIN_PYTHON_VERSION supported* +python-version = "3.11" +# By default ty also puts python/ first, which hides grass.lib because ty does +# not look for the rest of a package in other search paths. With only the +# repository root, the grass package comes from the GRASS build or installation, +# where it is complete with grass.lib. +root = ["."] + +[tool.ty.src] +include = ["python"] +exclude = [ + "**/__pycache__", + "python/grass/temporal/ply", + "python/libgrass_interface_generator", +] + +[tool.pyrefly] +# *GRASS TODO: keep in sync with MIN_PYTHON_VERSION supported* +python-version = "3.11" +project-includes = ["python"] +project-excludes = [ + "**/__pycache__", + "python/grass/temporal/ply", + "python/libgrass_interface_generator", +] + [tool.pyrefact] line_length = 88 diff --git a/utils/typecheck_env.py b/utils/typecheck_env.py new file mode 100644 index 00000000000..22c75274efb --- /dev/null +++ b/utils/typecheck_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +# MODULE: typecheck_env +# +# PURPOSE: Create a virtual environment for type checkers (ty, pyrefly, +# pyright) which provides the generated GRASS Python code +# +# SPDX-FileCopyrightText: 2026 GRASS Development Team +# SPDX-License-Identifier: GPL-2.0-or-later + +"""Make the generated code of a GRASS build or installation visible to type checkers + +The grass.lib package is generated during the build, so it is not in the source +tree. This script creates a virtual environment and adds a .pth file with the +location of the Python code of a built or installed GRASS to it. Type checkers +read the environment, so the project configuration does not need any +machine-specific paths. + +Usage: + + python utils/typecheck_env.py + python utils/typecheck_env.py --grass build/output/bin/grass --venv .venv +""" + +import argparse +import shutil +import subprocess +import sys +import venv +from pathlib import Path + +PTH_NAME = "grass_generated.pth" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--grass", + default="grass", + help="GRASS executable of the build or installation (default: grass on PATH)", + ) + parser.add_argument( + "--venv", + type=Path, + default=Path(".venv"), + help="virtual environment to create or update (default: .venv)", + ) + args = parser.parse_args() + + grass = shutil.which(args.grass) + if not grass: + sys.exit(f"GRASS executable '{args.grass}' not found") + python_path = subprocess.run( + [grass, "--config", "python_path"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if not (Path(python_path) / "grass" / "lib").is_dir(): + sys.exit(f"No grass.lib in {python_path}, is GRASS built with Python support?") + + if not args.venv.exists(): + venv.create(args.venv, with_pip=False, symlinks=True) + bin_dir = "Scripts" if sys.platform == "win32" else "bin" + venv_python = ( + args.venv / bin_dir / ("python.exe" if bin_dir == "Scripts" else "python") + ) + site_packages = Path( + subprocess.run( + [ + str(venv_python), + "-c", + "import sysconfig; print(sysconfig.get_path('purelib'))", + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + (site_packages / PTH_NAME).write_text(python_path + "\n", encoding="utf-8") + print(f"Added {python_path} to {args.venv}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())