瀏覽代碼

grass.script: Resolve path to mapset in setup.init (#1829)

This replaces the db/loc/mapset defaults in grass.script.setup.init which were for demolocation
by more useful behavior: Basic case is passing all three values as before, but when gisdbase
and location are provided and mapset is not PERMANENT is used. This is actually also the same as
before, but now it fails if location is not provided. Additionally, location and mapset parameters
can be left out if the gisdbase (aka path) parameter points to the mapset or location.
In that case, additional check is done to resolve to see if it is a valid mapset
if not, PERMANENT is used instead. If there is no PERMANENT, the path is left as is.
The grass.jupyter.init uses grass.script.setup.init, so these changes apply there, too.

The gisbase is now automatically detected if possible, but also customizable with new convenient options (see doc).

No more gisbase and gisdbase. Renamed to grass_path and path, respectively. Parameters are also reordered. This will break existing code, but for 7 to 8 switch that's okay.

A possibly breaking change of behavior is that the init function checks for validity of the mapset
and raises an exception if the mapset is invalid (does not exist or fails validity test from grass.grassdb).

Most of the original code from grass.script.setup.init is now in a separate function which sets up the
runtime environment (env vars for libs etc.), but does not do anything with the data (or session file/gisrc).

grass.grassdb now has a new MapsetPath class to simplify path operations around mapset and especially switching between db/loc/mapset and full path to mapset. New function resolve_mapset_path takes care of the path or db/loc/mapset to actual mapset conversion. MapsetPath is reusable and os.PathLike.

GIS_LOCK is moved in the code as it is a part of mapset/data session, not runtime.

Update the basic example notebook for the new API (no gisbase passed, full path to mapset is enough) and correct usage of the old one (no reference to rcfile, finish call added).

Use Path in split_mapset_path and use that in resolve_mapset_path.

Use Path.cwd() / path on Windows if the path does not exist.
Vaclav Petras 3 年之前
父節點
當前提交
ffb79ae031

+ 20 - 1
doc/notebooks/basic_example.ipynb

@@ -44,7 +44,7 @@
     "import grass.script.setup as gsetup\n",
     "import grass.script.setup as gsetup\n",
     "\n",
     "\n",
     "# Create a GRASS GIS session.\n",
     "# Create a GRASS GIS session.\n",
-    "rcfile = gsetup.init(gisbase, \"../../data/grassdata\", \"nc_basic_spm_grass7\", \"user1\")\n",
+    "gsetup.init(\"~/data/grassdata/nc_basic_spm_grass7/user1\")\n",
     "\n",
     "\n",
     "# We want functions to raise exceptions and see standard output of the modules in the notebook.\n",
     "# We want functions to raise exceptions and see standard output of the modules in the notebook.\n",
     "gs.set_raise_on_error(True)\n",
     "gs.set_raise_on_error(True)\n",
@@ -152,6 +152,25 @@
    "source": [
    "source": [
     "print(gs.read_command(\"g.search.modules\", flags=\"g\"))"
     "print(gs.read_command(\"g.search.modules\", flags=\"g\"))"
    ]
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## When the work finished\n",
+    "\n",
+    "When we are finished working the mapset, we should end the GRASS session using `finish()` which will remove the temporary files created in the background. After the call, GRASS modules can no longer be executed, so the call is commented out in this notebook to allow running all cells and, at the same time, going back and experimenting with the code."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Uncomment and run when done.\n",
+    "# gsetup.finish()"
+   ]
   }
   }
  ],
  ],
  "metadata": {
  "metadata": {

+ 135 - 3
python/grass/grassdb/manage.py

@@ -12,6 +12,11 @@ for details.
 
 
 import os
 import os
 import shutil
 import shutil
+import sys
+from pathlib import Path
+
+
+import grass.grassdb.config
 
 
 
 
 def delete_mapset(database, location, mapset):
 def delete_mapset(database, location, mapset):
@@ -48,8 +53,135 @@ def rename_location(database, old_name, new_name):
     os.rename(os.path.join(database, old_name), os.path.join(database, new_name))
     os.rename(os.path.join(database, old_name), os.path.join(database, new_name))
 
 
 
 
+class MapsetPath:
+    """This is a representation of a path to mapset.
+
+    Individual components are accessible through read-only properties
+    and objects have an os.PathLike interface.
+
+    Paths are currently stored as is (not resolved, not expanded),
+    but that may change in the future.
+    """
+
+    def __init__(self, path, directory, location, mapset):
+        # Path as an attribute. Inheriting from Path would be something to consider
+        # here, however the Path inheritance is somewhat complex at this point.
+        self._path = Path(path)
+        self._directory = str(directory)
+        self._location = location
+        self._mapset = mapset
+
+    def __repr__(self):
+        return (
+            f"{self.__class__.__name__}("
+            f"{self._path!r}, "
+            f"{self._directory!r}, {self._location!r}, {self._mapset!r})"
+        )
+
+    def __str__(self):
+        return str(self._path)
+
+    def __fspath__(self):
+        return os.fspath(self._path)
+
+    @property
+    def path(self):
+        """Full path to the mapset as a pathlib.Path object"""
+        return self._path
+
+    @property
+    def directory(self):
+        """Location name"""
+        return self._directory
+
+    @property
+    def location(self):
+        """Location name"""
+        return self._location
+
+    @property
+    def mapset(self):
+        """Mapset name"""
+        return self._mapset
+
+
 def split_mapset_path(mapset_path):
 def split_mapset_path(mapset_path):
     """Split mapset path to three parts - grassdb, location, mapset"""
     """Split mapset path to three parts - grassdb, location, mapset"""
-    path, mapset = os.path.split(mapset_path.rstrip(os.sep))
-    grassdb, location = os.path.split(path)
-    return grassdb, location, mapset
+    mapset_path = Path(mapset_path)
+    if len(mapset_path.parts) < 3:
+        ValueError(
+            _("Mapset path '{}' needs at least three components").format(mapset_path)
+        )
+    mapset = mapset_path.name
+    location_path = mapset_path.parent
+    location = location_path.name
+    grassdb = location_path.parent
+    return os.fspath(grassdb), location, mapset
+
+
+def resolve_mapset_path(path, location=None, mapset=None):
+    """Resolve full path to mapset from given combination of parameters.
+
+    Full or relative path to mapset can be provided as *path*. If the *path*
+    points to a valid location instead of a valid mapset, the mapset defaults
+    to PERMANENT.
+
+    Alternatively, location and mapset can be provided separately. In that case,
+    location and mapset are added to the path. If location is provided and mapset
+    is not, mapset defaults to PERMANENT.
+
+    Home represented by ``~`` (tilde) and relative paths are resolved
+    and the result contains absolute paths.
+
+    The function does not enforce the existence of the directory or that it
+    is a mapset. It only manipulates the paths except for internal checks
+    which help to determine the result in some cases. On Windows, if the path
+    does not exist and ``..`` is present in the path, it will be not be resolved
+    due to path resolution limitation in the Python pathlib package.
+
+    Returns a MapsetPath object.
+    """
+    # We reduce the top-level imports because this is initialization code.
+    # pylint: disable=import-outside-toplevel
+
+    path = Path(path).expanduser()
+    if not sys.platform.startswith("win") or path.exists():
+        # The resolve function works just fine on Windows when the path exists
+        # and everywhere even if it does not.
+        # This also resolves symlinks which may or may not be desired.
+        path = path.resolve()
+    else:
+        # On Windows when the path does not exist, resolve does not work.
+        # This does not resolve `..` which is not desired.
+        path = Path.cwd() / path
+    default_mapset = grass.grassdb.config.permanent_mapset
+    if location and mapset:
+        directory = str(path)
+        path = path / location / mapset
+    elif location:
+        mapset = default_mapset
+        directory = str(path)
+        path = path / location / mapset
+    elif mapset:
+        # mapset, but not location
+        raise ValueError(
+            _(
+                "Provide only path, or path and location, "
+                "or path, location, and mapset, but not mapset without location"
+            )
+        )
+    else:
+        from grass.grassdb.checks import is_mapset_valid
+
+        if not is_mapset_valid(path) and is_mapset_valid(path / default_mapset):
+            path = path / default_mapset
+        parts = path.parts
+        if len(parts) < 3:
+            raise ValueError(
+                _(
+                    "Parameter path needs to be 'path/to/location/mapset' "
+                    "or location and mapset need to be set"
+                )
+            )
+        directory, location, mapset = split_mapset_path(path)
+    return MapsetPath(path=path, directory=directory, location=location, mapset=mapset)

+ 161 - 0
python/grass/grassdb/testsuite/test_manage.py

@@ -0,0 +1,161 @@
+# MODULE:    Test of grass.grassdb.manage
+#
+# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
+#
+# PURPOSE:   Test of managing the GRASS database/location/mapset structure
+#
+# COPYRIGHT: (C) 2021 Vaclav Petras, and by the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+
+"""Tests of grass.grassdb.manage"""
+
+from pathlib import Path
+
+from grass.grassdb.manage import MapsetPath, resolve_mapset_path, split_mapset_path
+from grass.gunittest.case import TestCase
+from grass.gunittest.gmodules import call_module
+from grass.gunittest.main import test
+
+
+class TestMapsetPath(TestCase):
+    """Check that object can be constructed"""
+
+    def test_mapset_from_path_object(self):
+        """Check that path is correctly stored"""
+        path = "does/not/exist/"
+        location_name = "test_location_A"
+        mapset_name = "test_mapset_1"
+        full_path = Path(path) / location_name / mapset_name
+        mapset_path = MapsetPath(
+            path=full_path, directory=path, location=location_name, mapset=mapset_name
+        )
+        # Paths are currently stored as is (not resolved).
+        self.assertEqual(mapset_path.directory, path)
+        self.assertEqual(mapset_path.location, location_name)
+        self.assertEqual(mapset_path.mapset, mapset_name)
+        self.assertEqual(mapset_path.path, Path(path) / location_name / mapset_name)
+
+    def test_mapset_from_str(self):
+        """Check with path from str and database directory as Path"""
+        path = "does/not/exist"
+        location_name = "test_location_A"
+        mapset_name = "test_mapset_1"
+        full_path = Path(path) / location_name / mapset_name
+        mapset_path = MapsetPath(
+            path=str(full_path),
+            directory=Path(path),
+            location=location_name,
+            mapset=mapset_name,
+        )
+        # Paths are currently stored as is (not resolved).
+        self.assertEqual(mapset_path.directory, path)
+        self.assertEqual(mapset_path.location, location_name)
+        self.assertEqual(mapset_path.mapset, mapset_name)
+        self.assertEqual(mapset_path.path, Path(path) / location_name / mapset_name)
+
+
+class TestSplitMapsetPath(TestCase):
+    """Check that split works with different parameters"""
+
+    def test_split_path(self):
+        """Check that pathlib.Path is correctly split"""
+        ref_db = "does/not/exist"
+        ref_location = "test_location_A"
+        ref_mapset = "test_mapset_1"
+        path = Path(ref_db) / ref_location / ref_mapset
+        new_db, new_location, new_mapset = split_mapset_path(path)
+        self.assertEqual(new_db, ref_db)
+        self.assertEqual(new_location, ref_location)
+        self.assertEqual(new_mapset, ref_mapset)
+
+    def test_split_str(self):
+        """Check that path as str is correctly split"""
+        ref_db = "does/not/exist"
+        ref_location = "test_location_A"
+        ref_mapset = "test_mapset_1"
+        path = Path(ref_db) / ref_location / ref_mapset
+        new_db, new_location, new_mapset = split_mapset_path(str(path))
+        self.assertEqual(new_db, ref_db)
+        self.assertEqual(new_location, ref_location)
+        self.assertEqual(new_mapset, ref_mapset)
+
+    def test_split_str_trailing_slash(self):
+        """Check that path as str with a trailing slash is correctly split"""
+        ref_db = "does/not/exist"
+        ref_location = "test_location_A"
+        ref_mapset = "test_mapset_1"
+        path = Path(ref_db) / ref_location / ref_mapset
+        new_db, new_location, new_mapset = split_mapset_path(str(path) + "/")
+        self.assertEqual(new_db, ref_db)
+        self.assertEqual(new_location, ref_location)
+        self.assertEqual(new_mapset, ref_mapset)
+
+
+class TestResolveMapsetPath(TestCase):
+    """Check expected results for current mapset and for a non-existent one"""
+
+    def test_default_mapset_exists(self):
+        """Check that default mapset is found for real path/location.
+
+        The location (or mapset) may not exist, but exist in the test.
+        """
+        db_path = call_module("g.gisenv", get="GISDBASE").strip()
+        loc_name = call_module("g.gisenv", get="LOCATION_NAME").strip()
+        mapset_path = resolve_mapset_path(path=db_path, location=loc_name)
+        self.assertEqual(mapset_path.mapset, "PERMANENT")
+
+    def test_default_mapset_does_not_exist(self):
+        """Check that default mapset is found for non-existent path/location.
+
+        The location (or mapset) do not exist.
+        """
+        mapset_path = resolve_mapset_path(
+            path="does/not/exist", location="does_not_exit"
+        )
+        self.assertEqual(mapset_path.mapset, "PERMANENT")
+
+    def test_default_mapset_with_path(self):
+        """Check that default mapset is found for path.
+
+        This requires the location (with default mapset) to exists.
+        """
+        db_path = call_module("g.gisenv", get="GISDBASE").strip()
+        loc_name = call_module("g.gisenv", get="LOCATION_NAME").strip()
+        mapset_path = resolve_mapset_path(path=Path(db_path) / loc_name)
+        self.assertEqual(mapset_path.mapset, "PERMANENT")
+
+    def test_mapset_from_parts(self):
+        """Check that a non-existing path is correctly constructed."""
+        path = "does/not/exist"
+        location_name = "test_location_A"
+        mapset_name = "test_mapset_1"
+        mapset_path = resolve_mapset_path(
+            path=path, location=location_name, mapset=mapset_name
+        )
+        self.assertEqual(mapset_path.directory, str(Path(path).resolve()))
+        self.assertEqual(mapset_path.location, location_name)
+        self.assertEqual(mapset_path.mapset, mapset_name)
+        self.assertEqual(
+            mapset_path.path, Path(path).resolve() / location_name / mapset_name
+        )
+
+    def test_mapset_from_path(self):
+        """Check that a non-existing path is correctly parsed."""
+        path = "does/not/exist/"
+        location_name = "test_location_A"
+        mapset_name = "test_mapset_1"
+        full_path = str(Path(path) / location_name / mapset_name)
+        mapset_path = resolve_mapset_path(path=full_path)
+        self.assertEqual(mapset_path.directory, str(Path(path).resolve()))
+        self.assertEqual(mapset_path.location, location_name)
+        self.assertEqual(mapset_path.mapset, mapset_name)
+        self.assertEqual(
+            mapset_path.path, Path(path).resolve() / location_name / mapset_name
+        )
+
+
+if __name__ == "__main__":
+    test()

+ 2 - 2
python/grass/jupyter/setup.py

@@ -33,7 +33,7 @@ def _set_notebook_defaults():
     os.environ["GRASS_OVERWRITE"] = "1"
     os.environ["GRASS_OVERWRITE"] = "1"
 
 
 
 
-def init(path, location, mapset):
+def init(path, location=None, mapset=None, grass_path=None):
     """
     """
     This function initiates a GRASS session and sets GRASS
     This function initiates a GRASS session and sets GRASS
     environment variables.
     environment variables.
@@ -43,6 +43,6 @@ def init(path, location, mapset):
     :param str mapset: name of mapset within location
     :param str mapset: name of mapset within location
     """
     """
     # Create a GRASS GIS session.
     # Create a GRASS GIS session.
-    gsetup.init(os.environ["GISBASE"], path, location, mapset)
+    gsetup.init(path, location=location, mapset=mapset, grass_path=grass_path)
     # Set GRASS env. variables
     # Set GRASS env. variables
     _set_notebook_defaults()
     _set_notebook_defaults()

+ 169 - 44
python/grass/script/setup.py

@@ -11,9 +11,9 @@ Usage::
 
 
     # define GRASS Database
     # define GRASS Database
     # add your path to grassdata (GRASS GIS database) directory
     # add your path to grassdata (GRASS GIS database) directory
-    gisdb = os.path.join(os.path.expanduser("~"), "grassdata")
+    gisdb = "~/grassdata"
     # the following path is the default path on MS Windows
     # the following path is the default path on MS Windows
-    # gisdb = os.path.join(os.path.expanduser("~"), "Documents/grassdata")
+    # gisdb = "~/Documents/grassdata"
 
 
     # specify (existing) Location and Mapset
     # specify (existing) Location and Mapset
     location = "nc_spm_08"
     location = "nc_spm_08"
@@ -63,7 +63,7 @@ Usage::
     import grass.script.setup as gsetup
     import grass.script.setup as gsetup
 
 
     # launch session
     # launch session
-    rcfile = gsetup.init(gisbase, gisdb, location, mapset)
+    rcfile = gsetup.init(gisdb, location, mapset, grass_path=gisbase)
 
 
     # example calls
     # example calls
     gs.message('Current GRASS GIS 8 environment:')
     gs.message('Current GRASS GIS 8 environment:')
@@ -78,7 +78,7 @@ Usage::
         print(vect)
         print(vect)
 
 
     # clean up at the end
     # clean up at the end
-    gsetup.cleanup()
+    gsetup.finish()
 
 
 
 
 (C) 2010-2021 by the GRASS Development Team
 (C) 2010-2021 by the GRASS Development Team
@@ -96,11 +96,13 @@ for details.
 # is known, this would allow moving things from there, here
 # is known, this would allow moving things from there, here
 # then this could even do locking
 # then this could even do locking
 
 
+from pathlib import Path
 import os
 import os
+import shutil
+import subprocess
 import sys
 import sys
 import tempfile as tmpfile
 import tempfile as tmpfile
 
 
-
 windows = sys.platform == "win32"
 windows = sys.platform == "win32"
 
 
 
 
@@ -121,38 +123,90 @@ def set_gui_path():
         sys.path.insert(0, gui_path)
         sys.path.insert(0, gui_path)
 
 
 
 
-def init(gisbase, dbase="", location="demolocation", mapset="PERMANENT"):
-    """Initialize system variables to run GRASS modules
+def get_install_path(path=None):
+    """Get path to GRASS installation usable for setup of environmental variables.
 
 
-    This function is for running GRASS GIS without starting it with the
-    standard script grassXY. No GRASS modules shall be called before
-    call of this function but any module or user script can be called
-    afterwards because a GRASS session has been set up. GRASS Python
-    libraries are usable as well in general but the ones using C
-    libraries through ``ctypes`` are not (which is caused by library
-    path not being updated for the current process which is a common
-    operating system limitation).
+    The function tries to determine path tp GRASS GIS installation so that the
+    returned path can be used for setup of environmental variable for GRASS runtime.
+    If the search fails, None is returned.
 
 
-    To create a GRASS session a ``gisrc`` file is created.
-    Caller is responsible for deleting the ``gisrc`` file.
+    By default, the resulting path is derived relatively from the location of the
+    Python package (specifically this module) in the file system. This derived path
+    is returned only if it has subdirectories called ``bin`` and ``lib``.
+    If the parameter or certain environmental variables are set, the following
+    attempts are made to find the path.
 
 
-    Basic usage::
+    If *path* is provided and it is an existing executable, the executable is queried
+    for the path. Otherwise, provided *path* is returned as is.
 
 
-        # ... setup GISBASE and PYTHON path before import
-        import grass.script as gs
-        gisrc = gs.setup.init("/usr/bin/grass8",
-                              "/home/john/grassdata",
-                              "nc_spm_08", "user1")
-        # ... use GRASS modules here
-        # end the session
-        gs.setup.finish()
+    If *path* is not provided, the GISBASE environmental variable is used as the path
+    if it exists. If GRASSBIN environmental variable exists and it is an existing
+    executable, the executable is queried for the path.
 
 
-    :param gisbase: path to GRASS installation
-    :param dbase: path to GRASS database (default: '')
-    :param location: location name (default: 'demolocation')
-    :param mapset: mapset within given location (default: 'PERMANENT')
+    If *path* is not provided and no relevant environmental variables are set, the
+    default relative path search is performed.
+    If that fails and executable called ``grass`` exists, it is queried for the path.
+    None is returned if all the attempts failed.
 
 
-    :returns: path to ``gisrc`` file (to be deleted later)
+    If an existing executable is called as a subprocess is called during the search
+    and it fails, the CalledProcessError exception is propagated from the subprocess
+    call.
+    """
+
+    def ask_executable(arg):
+        """Query the GRASS exectable for the path"""
+        return subprocess.run(
+            [arg, "--config", "path"], text=True, check=True, capture_output=True
+        ).stdout.strip()
+
+    # Exectable was provided as parameter.
+    if path and shutil.which(path):
+        # The path was provided by the user and it is an executable
+        # (on path or provided with full path), so raise exception on failure.
+        ask_executable(path)
+
+    # Presumably directory was provided.
+    if path:
+        return path
+
+    # GISBASE is already set.
+    env_gisbase = os.environ.get("GISBASE")
+    if env_gisbase:
+        return env_gisbase
+
+    # Executable provided in environment (name is from grass-session).
+    # The variable is supported (here), documented, but not widely promoted
+    # at this point (to be re-evaluated).
+    grass_bin = os.environ.get("GRASSBIN")
+    if grass_bin and shutil.which(grass_bin):
+        ask_executable(grass_bin)
+
+    # Derive the path from path to this file (Python module).
+    # This is the standard way when there is no user-provided settings.
+    # Uses relative path to find the right parent and then tests presence of lib
+    # and bin. Removing 5 parts from the path works for
+    # .../grass_install_prefix/etc/python/grass and also .../python3/dist-packages/.
+    install_path = Path(*Path(__file__).parts[:-5])
+    bin_path = install_path / "bin"
+    lib_path = install_path / "lib"
+    if bin_path.is_dir() and lib_path.is_dir():
+        path = install_path
+
+    # As a last resort, try running grass command if it exists.
+    # This is less likely give the right result than the relative path on systems
+    # with multiple installations (where an explicit setup is likely required).
+    # However, it allows for non-standard installations with standard command.
+    grass_bin = "grass"
+    if grass_bin and shutil.which(grass_bin):
+        ask_executable(grass_bin)
+
+    return None
+
+
+def setup_runtime_env(gisbase):
+    """Setup the runtime environment.
+
+    Modifies the global environment (os.environ) so that GRASS modules can run.
     """
     """
     # Set GISBASE
     # Set GISBASE
     os.environ["GISBASE"] = gisbase
     os.environ["GISBASE"] = gisbase
@@ -182,9 +236,6 @@ def init(gisbase, dbase="", location="demolocation", mapset="PERMANENT"):
         os.environ["@LD_LIBRARY_PATH_VAR@"] = ""
         os.environ["@LD_LIBRARY_PATH_VAR@"] = ""
     os.environ["@LD_LIBRARY_PATH_VAR@"] += os.pathsep + os.path.join(gisbase, "lib")
     os.environ["@LD_LIBRARY_PATH_VAR@"] += os.pathsep + os.path.join(gisbase, "lib")
 
 
-    # TODO: lock the mapset?
-    os.environ["GIS_LOCK"] = str(os.getpid())
-
     # Set GRASS_PYTHON and PYTHONPATH to find GRASS Python modules
     # Set GRASS_PYTHON and PYTHONPATH to find GRASS Python modules
     if not os.getenv("GRASS_PYTHON"):
     if not os.getenv("GRASS_PYTHON"):
         if sys.platform == "win32":
         if sys.platform == "win32":
@@ -200,13 +251,86 @@ def init(gisbase, dbase="", location="demolocation", mapset="PERMANENT"):
         path = etcpy
         path = etcpy
     os.environ["PYTHONPATH"] = path
     os.environ["PYTHONPATH"] = path
 
 
-    # TODO: isn't this contra-productive? may fail soon since we cannot
-    # write to the installation (applies also to defaults for Location
-    # and mapset) I don't see what would be the use case here.
-    if not dbase:
-        dbase = gisbase
 
 
-    os.environ["GISRC"] = write_gisrc(dbase, location, mapset)
+def init(path, location=None, mapset=None, grass_path=None):
+    """Initialize system variables to run GRASS modules
+
+    This function is for running GRASS GIS without starting it with the
+    standard main executable grass. No GRASS modules shall be called before
+    call of this function but any module or user script can be called
+    afterwards because a GRASS session has been set up. GRASS Python
+    libraries are usable as well in general but the ones using C
+    libraries through ``ctypes`` are not (which is caused by library
+    path not being updated for the current process which is a common
+    operating system limitation).
+
+    When the path or specified mapset does not exist, ValueError is raised.
+
+    The :func:`get_install_path` function is used to determine where
+    the rest of GRASS files is installed. The *grass_path* parameter is
+    passed to it if provided. If the path cannot be determined,
+    ValueError is raised. Exceptions from the underlying function are propagated.
+
+    To create a GRASS session a session file (aka gisrc file) is created.
+    Caller is responsible for deleting the file which is normally done
+    with the function :func:`finish`.
+
+    Basic usage::
+
+        # ... setup GISBASE and sys.path before import
+        import grass.script as gs
+        gs.setup.init(
+            "~/grassdata/nc_spm_08/user1",
+            grass_path="/usr/lib/grass",
+        )
+        # ... use GRASS modules here
+        # end the session
+        gs.setup.finish()
+
+    :param path: path to GRASS database
+    :param location: location name
+    :param mapset: mapset within given location (default: 'PERMANENT')
+    :param grass_path: path to GRASS installation or executable
+
+    :returns: path to ``gisrc`` file (may change in future versions)
+    """
+    grass_path = get_install_path(grass_path)
+    if not grass_path:
+        raise ValueError(
+            _("Parameter grass_path or GISBASE environmental variable must be set")
+        )
+    # We reduce the top-level imports because this is initialization code.
+    # pylint: disable=import-outside-toplevel
+    from grass.grassdb.checks import get_mapset_invalid_reason, is_mapset_valid
+    from grass.grassdb.manage import resolve_mapset_path
+
+    # A simple existence test. The directory, whatever it is, should exist.
+    if not Path(path).exists():
+        raise ValueError(_("Path '{path}' does not exist").format(path=path))
+    # A specific message when it exists, but it is a file.
+    if Path(path).is_file():
+        raise ValueError(
+            _("Path '{path}' is a file, but a directory is needed").format(path=path)
+        )
+    mapset_path = resolve_mapset_path(path=path, location=location, mapset=mapset)
+    if not is_mapset_valid(mapset_path):
+        raise ValueError(
+            _("Mapset {path} is not valid: {reason}").format(
+                path=mapset_path.path,
+                reason=get_mapset_invalid_reason(
+                    mapset_path.directory, mapset_path.location, mapset_path.mapset
+                ),
+            )
+        )
+
+    setup_runtime_env(grass_path)
+
+    # TODO: lock the mapset?
+    os.environ["GIS_LOCK"] = str(os.getpid())
+
+    os.environ["GISRC"] = write_gisrc(
+        mapset_path.directory, mapset_path.location, mapset_path.mapset
+    )
     return os.environ["GISRC"]
     return os.environ["GISRC"]
 
 
 
 
@@ -214,8 +338,8 @@ def init(gisbase, dbase="", location="demolocation", mapset="PERMANENT"):
 # these fns can only be called within a valid GRASS session
 # these fns can only be called within a valid GRASS session
 def clean_default_db():
 def clean_default_db():
     # clean the default db if it is sqlite
     # clean the default db if it is sqlite
-    from grass.script import db as gdb
     from grass.script import core as gcore
     from grass.script import core as gcore
+    from grass.script import db as gdb
 
 
     conn = gdb.db_connection()
     conn = gdb.db_connection()
     if conn and conn["driver"] == "sqlite":
     if conn and conn["driver"] == "sqlite":
@@ -235,8 +359,6 @@ def clean_default_db():
 
 
 
 
 def call(cmd, **kwargs):
 def call(cmd, **kwargs):
-    import subprocess
-
     """Wrapper for subprocess.call to deal with platform-specific issues"""
     """Wrapper for subprocess.call to deal with platform-specific issues"""
     if windows:
     if windows:
         kwargs["shell"] = True
         kwargs["shell"] = True
@@ -262,7 +384,10 @@ def finish():
     Basic usage::
     Basic usage::
         import grass.script as gs
         import grass.script as gs
 
 
-        gs.setup.cleanup()
+        gs.setup.finish()
+
+    The function is not completely symmetrical with :func:`init` because it only
+    closes the mapset, but doesn't undo the runtime environment setup.
     """
     """
 
 
     clean_default_db()
     clean_default_db()