Source code for openghg.util._user
import logging
import os
import platform
from pathlib import Path
from typing import Dict, Optional
import uuid
import toml
import shutil
from openghg.types import ConfigFileError
logger = logging.getLogger("openghg.util")
logger.setLevel(logging.DEBUG) # Have to set level for logger as well as handler
openghg_config_filename = "openghg.conf"
# @lru_cache
[docs]
def get_user_id() -> str:
"""Return the user's ID
Returns:
str: User ID
"""
config = read_local_config()
uid = str(config.get("user_id", "NA"))
return uid
def get_default_objectstore_path() -> Path:
"""Returns the default object store path in the user's home directory
Returns:
Path: Object store path in ~/openghg_store
"""
return Path.home().joinpath("openghg_store").absolute()
# @lru_cache
[docs]
def get_user_config_path() -> Path:
"""Returns path to user config file.
This file is created in the user's home directory
in ~/.ghgconfig/openghg/user.conf on Linux / macOS or
in LOCALAPPDATA/openghg/openghg.conf on Windows.
Returns:
pathlib.Path: Path to user config file
"""
user_platform = platform.system()
if user_platform == "Windows":
appdata_path = os.getenv("LOCALAPPDATA")
if appdata_path is None:
raise ValueError("Unable to read LOCALAPPDATA environment variable.")
config_path = Path(appdata_path).joinpath("openghg", openghg_config_filename)
elif user_platform in ("Linux", "Darwin"):
config_path = Path.home().joinpath(".openghg", openghg_config_filename)
else:
raise ValueError(f"Unknown platform: {user_platform}")
return config_path
[docs]
def create_config(silent: bool = False) -> None:
"""Creates a user config.
Args:
silent: Creates the basic configuration file with only
the user's object store in a default location.
Returns:
None
"""
if not silent:
print("\nOpenGHG configuration")
print("---------------------\n")
user_config_path = get_user_config_path()
# Current config version as of version 0.6.0
config_version = "2"
positive_responses = ("y", "yes")
# If the config file exists we might need to update it due to the introduction
# of the user ID and new object store path handling for multiple stores
if user_config_path.exists():
if silent:
logger.error("Cannot update an existing configuration silently. Please run interactively.")
return
logger.info(f"User config exists at {str(user_config_path)}, checking...")
config = toml.loads(user_config_path.read_text())
recent = "config_version" in config
if recent:
user_store_path = Path(config["object_store"]["user"]["path"])
else:
user_store_path = Path(config["object_store"]["local_store"])
logger.info(f"Current user object store path: {user_store_path}")
# Store the object store info
stores = {}
update_input = input("Would you like to update the path? (y/n): ")
if update_input.lower() in positive_responses:
new_path_input = input("Enter new path for object store: ")
if not new_path_input:
print("You must enter a path. Unable to complete config setup.")
return
new_path = Path(new_path_input).expanduser().resolve()
stores["user"] = {"path": str(new_path), "permissions": "rw"}
else:
stores["user"] = {"path": str(user_store_path), "permissions": "rw"}
# Copy in exisiting shared stores
if recent:
stores.update({k: v for k, v in config["object_store"].items() if k != "user"})
# Now ask the user if they want to add new stores
new_shared_stores = _user_multstore_input()
if new_shared_stores:
existing = [k for k in new_shared_stores if k in stores]
existing_paths = [path_data["path"] for path_data in stores.values()]
# Here it checks if newly entered paths are already present in config file.
duplicate_paths_with_store_name = {
store_name: path_data
for store_name, path_data in new_shared_stores.items()
if path_data["path"] in existing_paths
}
if existing:
print(f"Some names match those of existing stores: {existing}, please update manually.")
if duplicate_paths_with_store_name:
raise ValueError(
f"Paths of the following new stores match those of existing store: {duplicate_paths_with_store_name}"
)
stores.update(new_shared_stores)
# Some users may not have a user ID if they've used previous versions of OpenGHG
user_id = config.get("user_id")
config = _combine_config(config_version=config_version, object_stores=stores, user_id=user_id)
else:
# Let's try migrating the old config
# If it works we call this function again
# otherwise continue on to create a new config
try:
_migrate_config()
except FileNotFoundError:
pass
else:
create_config(silent=silent)
return
stores = {}
# 1. Create the user's object store first
if not silent:
logger.info("We'll first create your user object store.\n")
obj_store_path = get_default_objectstore_path()
if silent:
obj_store_path = get_default_objectstore_path()
else:
obj_store_input = input(f"Enter path for your local object store (default {obj_store_path}): ")
if obj_store_input:
obj_store_path = Path(obj_store_input).expanduser().resolve()
# Let's create the store to make sure it's a valid path
obj_store_path.mkdir(parents=True, exist_ok=True)
stores["user"] = {"path": str(obj_store_path), "permissions": "rw"}
if not silent:
shared_stores = _user_multstore_input()
stores.update(shared_stores)
config = _combine_config(config_version=config_version, object_stores=stores)
# Make the .config/openghg folder
user_config_path.parent.mkdir(parents=True, exist_ok=True)
if not silent:
logger.info(f"Configuration written to {user_config_path}")
user_config_path.write_text(toml.dumps(config))
def _user_multstore_input() -> Dict:
"""Ask the user to input data about shared object stores
Returns:
dict: Dictionary of object store paths and permissions
"""
positive_responses = ("y", "yes")
stores = {}
# 2. Ask the user to enter other object store paths
while True:
response = input("Would you like to add another object store? (y/n): ")
if response.lower() in positive_responses:
store_name = input("Enter the name of the store: ")
store_path = input("Enter the object store path: ")
print("\nYou will now be asked for read/write permissions for the store.")
print("For read only enter r, for read and write enter rw.")
store_permissions = ""
while store_permissions not in ("r", "rw"):
store_permissions = input("\nEnter object store permissions: ")
stores[store_name] = {"path": store_path, "permissions": store_permissions}
else:
break
return stores
def _combine_config(config_version: str, object_stores: Dict, user_id: Optional[str] = None) -> Dict:
"""Combine parts required into the proper dictionary format
Args:
config_version: Configuration version number
object_stores: Object store configuration dictionary
user_id: User ID
"""
# Create the object store dictionary
object_store_info = {}
for name, data in object_stores.items():
path = str(Path(data["path"]).expanduser().resolve())
permissions = data["permissions"].strip()
object_store_info[name] = {"path": path, "permissions": permissions}
if user_id is None:
user_id = str(uuid.uuid4())
return {"user_id": user_id, "config_version": config_version, "object_store": object_store_info}
# @lru_cache
[docs]
def read_local_config() -> Dict:
"""Reads the local config file.
Returns:
dict: OpenGHG configurations
"""
config_path = get_user_config_path()
if not config_path.exists():
try:
_migrate_config()
except FileNotFoundError as e:
raise ConfigFileError(
"Unable to read configuration file, please see the installation instructions \
or run openghg --quickstart"
) from e
config: Dict = toml.loads(config_path.read_text())
try:
_ = config["object_store"]["user"]
except KeyError:
raise ConfigFileError(
"Invalid config file detected, please please see the installation instructions \
or run openghg --quickstart"
)
# Check see is the store uses the new zarr storage format
# for OpenGHG >= 0.8.0
valid_stores = {}
for name, store_data in config["object_store"].items():
store_path = Path(store_data["path"])
# If it doesn't exist or its empty then we expect it to be created / populated
if not store_path.exists() or not any(store_path.iterdir()):
valid_stores[name] = store_data
# Otherwise we check an existing store to see if it's the correct format
else:
if _check_valid_store(store_path):
valid_stores[name] = store_data
else:
logger.warning(
f"Object store {name} does not use the new Zarr storage format and will be ignored."
)
if not valid_stores:
raise ConfigFileError(
"We've only detected old (non-Zarr) object stores. Please update your configuration to add a new path."
)
config["object_store"] = valid_stores
return config
[docs]
def check_config() -> None:
"""Check that the user config file is valid and the paths
given in it exist. Raises ConfigFileError if problems found.
Returns:
None
"""
config_path = get_user_config_path()
please_update = "please run openghg --quickstart to update it."
if not config_path.exists():
raise ConfigFileError(
"Configuration file does not exist. Please create it by running openghg --quickstart."
)
config = read_local_config()
try:
uid = config["user_id"]
except KeyError:
raise ConfigFileError("Unable to read user ID, ")
try:
uuid.UUID(uid, version=4)
except ValueError:
raise ConfigFileError(f"Invalid user ID, {please_update}")
try:
_ = config["config_version"]
except KeyError:
raise ConfigFileError(f"Invalid config file, {please_update}")
try:
object_stores = config["object_store"]
except KeyError:
raise ConfigFileError(f"Unable to read object store data, {please_update}")
for name, data in object_stores.items():
p = Path(data["path"])
if not p.exists():
logger.info(f"The path for object store {name} at {p} does not exist but will be created.")
def _migrate_config() -> None:
"""If user config file is in ~/.config, move it to ~/.openghg.
If no config is found in ~/.config or system is Windows, raise FileNotFoundError.
Returns:
None
"""
old_config_path = Path.home().joinpath(".config", "openghg", openghg_config_filename)
if old_config_path.exists():
new_config_path = get_user_config_path()
new_config_path.parent.mkdir(parents=True)
shutil.move(str(old_config_path), str(new_config_path))
logger.info(f"Moved user config file from {str(old_config_path)} to {str(new_config_path)}.")
shutil.rmtree(old_config_path.parent) # remove "openghg" dir from ~/.config
else:
raise FileNotFoundError("Configuration file not found.")
def _check_valid_store(store_path: Path) -> bool:
"""Checks if the store is a valid object store using the new Zarr storage
format. If it is return True, otherwise False.
TODO - remove this when users have all moved to the new object store format.
Args:
store_path: Object store path
Returns:
bool: True if valid, False if not
"""
data_dir = Path(store_path).joinpath("data")
# Now check if there's a zarr folder in the data directory
store_dirs = list(data_dir.glob("*"))
# Let's take the first data directory and see if there's a zarr folder in it
if not store_dirs:
logger.info(
f"No data found in the object store {store_path}, "
"so we are treating this empty store as a zarr store."
)
return True
store_data_dir = store_dirs[0]
return store_data_dir.joinpath("zarr").exists()