platypush/platypush/config/__init__.py

454 lines
15 KiB
Python
Raw Normal View History

import datetime
import glob
import importlib
import inspect
2017-12-18 01:10:51 +01:00
import logging
import os
import pathlib
import pkgutil
2019-12-08 16:25:03 +01:00
import re
import shutil
2017-12-18 01:10:51 +01:00
import socket
2018-04-14 00:59:56 +02:00
import sys
from urllib.parse import quote
from typing import Optional, Set
2017-12-18 01:10:51 +01:00
import yaml
from platypush.utils import (
get_hash,
is_functional_procedure,
is_functional_hook,
is_functional_cron,
)
class Config:
2017-12-18 01:10:51 +01:00
"""
Configuration base class
Usage:
- Initialize config from one of the default paths:
Config.init()
- Initialize config from a custom path
Config.init(config_file_path)
- Get a value
Config.get('foo')
"""
# Default config file locations:
# - $HOME/.config/platypush/config.yaml
# - /etc/platypush/config.yaml
2017-12-18 01:10:51 +01:00
_cfgfile_locations = [
os.path.join(os.path.expanduser('~'), '.config', 'platypush', 'config.yaml'),
2017-12-18 01:10:51 +01:00
os.path.join(os.sep, 'etc', 'platypush', 'config.yaml'),
]
# Config singleton instance
_instance = None
_default_constants = {
2018-01-15 22:36:24 +01:00
'today': datetime.date.today,
'now': datetime.datetime.now,
}
_workdir_location = os.path.join(
os.path.expanduser('~'), '.local', 'share', 'platypush'
)
_included_files: Set[str] = set()
2017-12-18 01:10:51 +01:00
def __init__(self, cfgfile=None):
"""
Constructor. Always use the class as a singleton (i.e. through
Config.init), you won't probably need to call the constructor directly
Params:
cfgfile -- Config file path (default: retrieve the first
available location in _cfgfile_locations)
"""
if cfgfile is None:
cfgfile = self._get_default_cfgfile()
if cfgfile is None:
cfgfile = self._create_default_config()
2017-12-18 01:10:51 +01:00
cfgfile = self._cfgfile = os.path.abspath(os.path.expanduser(cfgfile))
self._config = self._read_config_file(self._cfgfile)
2017-12-18 01:10:51 +01:00
if 'token' in self._config:
self._config['token_hash'] = get_hash(self._config['token'])
if 'workdir' not in self._config:
self._config['workdir'] = self._workdir_location
self._config['workdir'] = os.path.expanduser(self._config['workdir'])
2018-01-27 13:54:15 +01:00
os.makedirs(self._config['workdir'], exist_ok=True)
if 'scripts_dir' not in self._config:
self._config['scripts_dir'] = os.path.join(
os.path.dirname(cfgfile), 'scripts'
)
os.makedirs(self._config['scripts_dir'], mode=0o755, exist_ok=True)
2020-11-21 01:12:08 +01:00
if 'dashboards_dir' not in self._config:
self._config['dashboards_dir'] = os.path.join(
os.path.dirname(cfgfile), 'dashboards'
)
2020-11-21 01:12:08 +01:00
os.makedirs(self._config['dashboards_dir'], mode=0o755, exist_ok=True)
# Create a default (empty) __init__.py in the scripts folder
init_py = os.path.join(self._config['scripts_dir'], '__init__.py')
if not os.path.isfile(init_py):
with open(init_py, 'w') as f:
f.write('# Auto-generated __init__.py - do not remove\n')
# Include scripts_dir parent in sys.path so members can be imported in scripts
# through the `scripts` package
scripts_parent_dir = str(
pathlib.Path(self._config['scripts_dir']).absolute().parent
)
sys.path = [scripts_parent_dir] + sys.path
# Initialize the default db connection string
db_engine = self._config.get('main.db', '')
if db_engine:
if isinstance(db_engine, str):
db_engine = {
'engine': db_engine,
}
else:
db_engine = {
'engine': 'sqlite:///'
+ os.path.join(quote(self._config['workdir']), 'main.db')
}
self._config['db'] = db_engine
2018-04-14 00:59:56 +02:00
logging_config = {
'level': logging.INFO,
'stream': sys.stdout,
'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s',
}
if 'logging' in self._config:
for (k, v) in self._config['logging'].items():
2018-04-14 00:59:56 +02:00
if k == 'filename':
logfile = os.path.expanduser(v)
logdir = os.path.dirname(logfile)
try:
os.makedirs(logdir, exist_ok=True)
except Exception as e:
print(f'Unable to create logs directory {logdir}: {e}')
2018-06-06 20:09:18 +02:00
v = logfile
2018-04-14 00:59:56 +02:00
del logging_config['stream']
2018-06-06 20:09:18 +02:00
elif k == 'level':
try:
v = int(v)
except ValueError:
v = getattr(logging, v.upper())
logging_config[k] = v
2018-04-14 00:59:56 +02:00
self._config['logging'] = logging_config
2017-12-18 01:10:51 +01:00
if 'device_id' not in self._config:
self._config['device_id'] = socket.gethostname()
if 'environment' in self._config:
for k, v in self._config['environment'].items():
os.environ[k] = str(v)
self.backends = {}
2017-12-18 01:10:51 +01:00
self.plugins = {}
self.event_hooks = {}
self.procedures = {}
self.constants = {}
2018-01-15 22:44:57 +01:00
self.cronjobs = {}
2020-11-21 01:12:08 +01:00
self.dashboards = {}
self._plugin_manifests = {}
self._backend_manifests = {}
self._init_manifests()
self._init_constants()
self._load_scripts()
self._init_components()
2020-11-21 01:12:08 +01:00
self._init_dashboards(self._config['dashboards_dir'])
def _create_default_config(self):
cfg_mod_dir = os.path.dirname(os.path.abspath(__file__))
cfgfile = self._cfgfile_locations[0]
cfgdir = pathlib.Path(cfgfile).parent
cfgdir.mkdir(parents=True, exist_ok=True)
for cfgfile in glob.glob(os.path.join(cfg_mod_dir, 'config*.yaml')):
shutil.copy(cfgfile, str(cfgdir))
return cfgfile
def _read_config_file(self, cfgfile):
cfgfile_dir = os.path.dirname(os.path.abspath(os.path.expanduser(cfgfile)))
config = {}
with open(cfgfile, 'r') as fp:
2018-06-18 13:07:20 +02:00
file_config = yaml.safe_load(fp)
if not file_config:
return config
for section in file_config:
if section == 'include':
include_files = (
file_config[section]
if isinstance(file_config[section], list)
else [file_config[section]]
)
for include_file in include_files:
if not include_file:
continue
if not os.path.isabs(include_file):
include_file = os.path.join(cfgfile_dir, include_file)
self._included_files.add(include_file)
config.update(self._read_config_file(include_file))
elif section == 'scripts_dir':
assert isinstance(file_config[section], str)
config['scripts_dir'] = os.path.abspath(
os.path.expanduser(file_config[section])
)
else:
section_config = file_config.get(section, {}) or {}
if not (
hasattr(section_config, 'get') and section_config.get('disabled')
):
config[section] = section_config
return config
def _load_module(self, modname: str, prefix: Optional[str] = None):
try:
module = importlib.import_module(modname)
except Exception as e:
print(f'Unhandled exception while importing module {modname}: {e}')
return
prefix = modname + '.' if prefix is None else prefix
self.procedures.update(
**{
prefix + name: obj
for name, obj in inspect.getmembers(module)
if is_functional_procedure(obj)
}
)
self.event_hooks.update(
**{
prefix + name: obj
for name, obj in inspect.getmembers(module)
if is_functional_hook(obj)
}
)
self.cronjobs.update(
**{
prefix + name: obj
for name, obj in inspect.getmembers(module)
if is_functional_cron(obj)
}
)
def _load_scripts(self):
scripts_dir = self._config['scripts_dir']
sys_path = sys.path.copy()
sys.path = [scripts_dir] + sys.path
scripts_modname = os.path.basename(scripts_dir)
self._load_module(scripts_modname, prefix='')
for _, modname, _ in pkgutil.walk_packages(
path=[scripts_dir], onerror=lambda _: None
):
self._load_module(modname)
sys.path = sys_path
def _init_components(self):
for key, component in self._config.items():
if (
key.startswith('backend.')
and '.'.join(key.split('.')[1:]) in self._backend_manifests
):
backend_name = '.'.join(key.split('.')[1:])
self.backends[backend_name] = component
elif key.startswith('event.hook.'):
hook_name = '.'.join(key.split('.')[2:])
self.event_hooks[hook_name] = component
2018-01-15 22:44:57 +01:00
elif key.startswith('cron.'):
cron_name = '.'.join(key.split('.')[1:])
self.cronjobs[cron_name] = component
elif key.startswith('procedure.'):
tokens = key.split('.')
_async = bool(len(tokens) > 2 and tokens[1] == 'async')
procedure_name = '.'.join(tokens[2:] if len(tokens) > 2 else tokens[1:])
2019-12-08 16:25:03 +01:00
args = []
m = re.match(r'^([^(]+)\(([^)]+)\)\s*', procedure_name)
if m:
procedure_name = m.group(1).strip()
args = [
arg.strip()
for arg in m.group(2).strip().split(',')
if arg.strip()
]
self.procedures[procedure_name] = {
'_async': _async,
'actions': component,
2019-12-08 16:25:03 +01:00
'args': args,
}
elif key in self._plugin_manifests:
self.plugins[key] = component
2017-12-18 01:10:51 +01:00
def _init_manifests(self, base_dir: Optional[str] = None):
if not base_dir:
base_dir = os.path.abspath(os.path.join(__file__, '..', '..'))
plugins_dir = os.path.join(base_dir, 'plugins')
backends_dir = os.path.join(base_dir, 'backend')
self._init_manifests(plugins_dir)
self._init_manifests(backends_dir)
else:
manifests_map = (
self._plugin_manifests
if base_dir.endswith('plugins')
else self._backend_manifests
)
for mf in pathlib.Path(base_dir).rglob('manifest.yaml'):
with open(mf, 'r') as f:
manifest = yaml.safe_load(f)['manifest']
comp_name = '.'.join(manifest['package'].split('.')[2:])
manifests_map[comp_name] = manifest
def _init_constants(self):
if 'constants' in self._config:
self.constants = self._config['constants']
for (key, value) in self._default_constants.items():
self.constants[key] = value
def _get_dashboard(
self, name: str, dashboards_dir: Optional[str] = None
) -> Optional[str]:
dashboards_dir = dashboards_dir or self._config['dashboards_dir']
assert dashboards_dir
2020-11-21 01:12:08 +01:00
abspath = os.path.join(dashboards_dir, name + '.xml')
if not os.path.isfile(abspath):
return None
2020-11-21 01:12:08 +01:00
with open(abspath, 'r') as fp:
return fp.read()
def _get_dashboards(self, dashboards_dir: Optional[str] = None) -> dict:
2020-11-21 01:12:08 +01:00
dashboards = {}
dashboards_dir = dashboards_dir or self._config['dashboards_dir']
assert dashboards_dir
2020-11-21 01:12:08 +01:00
for f in os.listdir(dashboards_dir):
abspath = os.path.join(dashboards_dir, f)
if not os.path.isfile(abspath) or not abspath.endswith('.xml'):
continue
name = f.split('.xml')[0]
dashboards[name] = self._get_dashboard(name, dashboards_dir)
2020-11-21 01:12:08 +01:00
return dashboards
@classmethod
def _get_instance(
cls, cfgfile: Optional[str] = None, force_reload: bool = False
) -> "Config":
"""
Lazy getter/setter for the default configuration instance.
"""
if force_reload or cls._instance is None:
cfg_args = [cfgfile] if cfgfile else []
cls._instance = Config(*cfg_args)
return cls._instance
@classmethod
def get_dashboard(
cls, name: str, dashboards_dir: Optional[str] = None
) -> Optional[str]:
# pylint: disable=protected-access
return cls._get_instance()._get_dashboard(name, dashboards_dir)
@classmethod
def get_dashboards(cls, dashboards_dir: Optional[str] = None) -> dict:
# pylint: disable=protected-access
return cls._get_instance()._get_dashboards(dashboards_dir)
2020-11-21 01:12:08 +01:00
def _init_dashboards(self, dashboards_dir: str):
self.dashboards = self._get_dashboards(dashboards_dir)
2020-11-21 01:12:08 +01:00
@classmethod
def get_backends(cls):
return cls._get_instance().backends
@classmethod
def get_plugins(cls):
return cls._get_instance().plugins
@classmethod
def get_event_hooks(cls):
return cls._get_instance().event_hooks
@classmethod
def get_procedures(cls):
return cls._get_instance().procedures
@classmethod
def get_constants(cls):
return {
name: Config.get_constant(name) for name in cls._get_instance().constants
}
@classmethod
def get_constant(cls, name):
value = cls._get_instance().constants.get(name)
if value is None:
return None
return value() if callable(value) else value
@classmethod
def get_cronjobs(cls):
return cls._get_instance().cronjobs
2018-01-15 22:36:24 +01:00
2017-12-18 01:10:51 +01:00
@classmethod
def _get_default_cfgfile(cls) -> Optional[str]:
2017-12-18 01:10:51 +01:00
for location in cls._cfgfile_locations:
if os.path.isfile(location):
return location
return None
2017-12-18 01:10:51 +01:00
@classmethod
def init(cls, cfgfile: Optional[str] = None):
2017-12-18 01:10:51 +01:00
"""
Initializes the config object singleton
Params:
cfgfile -- path to the config file - default: _cfgfile_locations
"""
return cls._get_instance(cfgfile, force_reload=True)
2017-12-18 01:10:51 +01:00
@classmethod
def get(cls, key: Optional[str] = None):
2017-12-18 01:10:51 +01:00
"""
Get a config value or the whole configuration object.
:param key: Configuration entry to get (default: all entries).
2017-12-18 01:10:51 +01:00
"""
# pylint: disable=protected-access
config = cls._get_instance()._config.copy()
if key:
return config.get(key)
return config
2017-12-18 01:10:51 +01:00
# vim:sw=4:ts=4:et: