Skip to content

Commit

Permalink
grass.script: Resolve path to mapset in setup.init (#1829)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
wenzeslaus committed Oct 2, 2021
1 parent c84312a commit ffb79ae
Show file tree
Hide file tree
Showing 5 changed files with 487 additions and 50 deletions.
21 changes: 20 additions & 1 deletion doc/notebooks/basic_example.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"import grass.script.setup as gsetup\n",
"\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",
"# We want functions to raise exceptions and see standard output of the modules in the notebook.\n",
"gs.set_raise_on_error(True)\n",
Expand Down Expand Up @@ -152,6 +152,25 @@
"source": [
"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": {
Expand Down
138 changes: 135 additions & 3 deletions python/grass/grassdb/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

import os
import shutil
import sys
from pathlib import Path


import grass.grassdb.config


def delete_mapset(database, location, mapset):
Expand Down Expand Up @@ -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))


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):
"""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 changes: 161 additions & 0 deletions python/grass/grassdb/testsuite/test_manage.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 2 additions & 2 deletions python/grass/jupyter/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def _set_notebook_defaults():
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
environment variables.
Expand All @@ -43,6 +43,6 @@ def init(path, location, mapset):
:param str mapset: name of mapset within location
"""
# 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_notebook_defaults()

0 comments on commit ffb79ae

Please sign in to comment.