Skip to content

[Bug] grass.script.setup: Repeated init() on the same environment grows PATH, PYTHONPATH, and the library path variable #7738

Description

@wenzeslaus

Describe the bug

When gs.setup.init() is called repeatedly on the same environment, typically the global os.environ in a long-lived process (a pytest run, a Jupyter kernel), each call adds the GRASS paths to PATH, PYTHONPATH, and the dynamic library path variable again, without checking whether they are already there, and finish() does not remove them. The variables grow without bound. A single init() on a fresh environment, e.g., env=os.environ.copy() per session, is not affected.

Details (analysis generated with Claude Code)

init() calls setup_runtime_env() unconditionally (python/grass/script/setup.py:404). A guard exists (runtime_env_is_active() used by ensure_runtime_env(), setup.py:276), but init() does not use it. Inside setup_runtime_env(), none of the path setters in python/grass/app/runtime.py check whether an entry is already present:

  • set_executable_paths() (runtime.py:262) prepends GISBASE/bin, GISBASE/scripts (or extrabin on Windows), the addon bin and scripts, and any GRASS_ADDON_PATH entries to PATH.
  • set_dynamic_library_path() (runtime.py:325) appends GISBASE/lib to the platform's library path variable: LD_LIBRARY_PATH on Linux, LD_RUN_PATH on macOS, and PATH on Windows, so on Windows PATH grows at both ends. When the variable is unset, it first creates it empty and then appends, so the value starts with a separator, i.e., an empty entry, which for LD_LIBRARY_PATH means the current directory.
  • set_python_path_variable() (runtime.py:332) prepends GISBASE/etc/python to PYTHONPATH.

(GRASS_PYTHON and GRASS_ADDON_BASE are set only when unset, so they do not accumulate.)

finish() is documented as asymmetrical ("only closes the mapset, but doesn't undo the runtime environment setup"), so repeated init()/finish() cycles accumulate.

Measured on Linux, repeating init() on the same environment: 10 calls grow PATH from 443 to 4483 characters (20 to 60 entries), +404 characters and +4 entries per call. PYTHONPATH and LD_LIBRARY_PATH grow correspondingly.

Where it bites:

Suggested fix: make the three setters idempotent, skipping an entry that is already in the variable. First-call behavior stays identical, all callers are fixed, and switching between installations keeps working since the new installation's entries are still prepended in front. Making init() skip the setup via ensure_runtime_env() instead would be coarser: runtime_env_is_active() only checks that GISBASE is a substring of PATH, and would skip a needed re-setup when the installation path changes. The leading empty entry in set_dynamic_library_path() is worth fixing along the way.

To reproduce

import os
import grass.script as gs

for i in range(10):
    project = f"/tmp/project_{i}"
    gs.create_project(project)
    with gs.setup.init(project):
        pass
    print(len(os.environ["PATH"]))

PATH grows by four entries per iteration; the same happens with PYTHONPATH and LD_LIBRARY_PATH.

Expected behavior

A second init() on an environment which already contains the GRASS paths does not add them again.

System description

All platforms; on Windows the growth is doubled into PATH. Observed on main, but the behavior is old.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions