1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2022 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Nominatim configuration accessor.
10 from typing import Dict, Any, List, Mapping, Optional
15 from pathlib import Path
19 from dotenv import dotenv_values
20 from psycopg2.extensions import parse_dsn
22 from nominatim.typing import StrPath
23 from nominatim.errors import UsageError
24 import nominatim.paths
26 LOG = logging.getLogger()
27 CONFIG_CACHE : Dict[str, Any] = {}
29 def flatten_config_list(content: Any, section: str = '') -> List[Any]:
30 """ Flatten YAML configuration lists that contain include sections
31 which are lists themselves.
36 if not isinstance(content, list):
37 raise UsageError(f"List expected in section '{section}'.")
41 if isinstance(ele, list):
42 output.extend(flatten_config_list(ele, section))
50 """ The `Configuration` class wraps access to the local configuration
51 options as described in the [Configuration page](../customize/Settings.md).
53 Nominatim uses dotenv to configure the software. Configuration options
54 are resolved in the following order:
56 * from the OS environment (or the dictionary given in `environ`)
57 * from the .env file in the project directory of the installation
58 * from the default installation in the configuration directory
60 All Nominatim configuration options are prefixed with 'NOMINATIM_' to
61 avoid conflicts with other environment variables. All settings can
62 be accessed as properties of the class under the same name as the
63 setting but with the `NOMINATIM_` prefix removed. In addition, there
64 are accessor functions that convert the setting values to types
68 def __init__(self, project_dir: Optional[Path],
69 environ: Optional[Mapping[str, str]] = None) -> None:
70 self.environ = environ or os.environ
71 self.project_dir = project_dir
72 self.config_dir = nominatim.paths.CONFIG_DIR
73 self._config = dotenv_values(str(self.config_dir / 'env.defaults'))
74 if self.project_dir is not None and (self.project_dir / '.env').is_file():
75 self.project_dir = self.project_dir.resolve()
76 self._config.update(dotenv_values(str(self.project_dir / '.env')))
81 php = nominatim.paths.PHPLIB_DIR
82 sql = nominatim.paths.SQLLIB_DIR
83 data = nominatim.paths.DATA_DIR
85 self.lib_dir = _LibDirs()
86 self._private_plugins: Dict[str, object] = {}
89 def set_libdirs(self, **kwargs: StrPath) -> None:
90 """ Set paths to library functions and data.
92 for key, value in kwargs.items():
93 setattr(self.lib_dir, key, Path(value))
96 def __getattr__(self, name: str) -> str:
97 name = 'NOMINATIM_' + name
99 if name in self.environ:
100 return self.environ[name]
102 return self._config[name] or ''
105 def get_bool(self, name: str) -> bool:
106 """ Return the given configuration parameter as a boolean.
107 Values of '1', 'yes' and 'true' are accepted as truthy values,
108 everything else is interpreted as false.
111 name: Name of the configuration parameter with the NOMINATIM_
115 `True` for values of '1', 'yes' and 'true', `False` otherwise.
117 return getattr(self, name).lower() in ('1', 'yes', 'true')
120 def get_int(self, name: str) -> int:
121 """ Return the given configuration parameter as an int.
124 name: Name of the configuration parameter with the NOMINATIM_
128 The configuration value converted to int.
131 ValueError: when the value is not a number.
134 return int(getattr(self, name))
135 except ValueError as exp:
136 LOG.fatal("Invalid setting NOMINATIM_%s. Needs to be a number.", name)
137 raise UsageError("Configuration error.") from exp
140 def get_str_list(self, name: str) -> Optional[List[str]]:
141 """ Return the given configuration parameter as a list of strings.
142 The values are assumed to be given as a comma-sparated list and
143 will be stripped before returning them. On empty values None
146 raw = getattr(self, name)
148 return [v.strip() for v in raw.split(',')] if raw else None
151 def get_path(self, name: str) -> Optional[Path]:
152 """ Return the given configuration parameter as a Path.
153 If a relative path is configured, then the function converts this
154 into an absolute path with the project directory as root path.
155 If the configuration is unset, None is returned.
157 value = getattr(self, name)
161 cfgpath = Path(value)
163 if not cfgpath.is_absolute():
164 assert self.project_dir is not None
165 cfgpath = self.project_dir / cfgpath
167 return cfgpath.resolve()
170 def get_libpq_dsn(self) -> str:
171 """ Get configured database DSN converted into the key/value format
172 understood by libpq and psycopg.
174 dsn = self.DATABASE_DSN
176 def quote_param(param: str) -> str:
177 key, val = param.split('=')
178 val = val.replace('\\', '\\\\').replace("'", "\\'")
180 val = "'" + val + "'"
181 return key + '=' + val
183 if dsn.startswith('pgsql:'):
184 # Old PHP DSN format. Convert before returning.
185 return ' '.join([quote_param(p) for p in dsn[6:].split(';')])
190 def get_database_params(self) -> Mapping[str, str]:
191 """ Get the configured parameters for the database connection
194 dsn = self.DATABASE_DSN
196 if dsn.startswith('pgsql:'):
197 return dict((p.split('=', 1) for p in dsn[6:].split(';')))
199 return parse_dsn(dsn)
202 def get_import_style_file(self) -> Path:
203 """ Return the import style file as a path object. Translates the
204 name of the standard styles automatically into a file in the
207 style = getattr(self, 'IMPORT_STYLE')
209 if style in ('admin', 'street', 'address', 'full', 'extratags'):
210 return self.config_dir / f'import-{style}.lua'
212 return self.find_config_file('', 'IMPORT_STYLE')
215 def get_os_env(self) -> Dict[str, str]:
216 """ Return a copy of the OS environment with the Nominatim configuration
219 env = {k: v for k, v in self._config.items() if v is not None}
220 env.update(self.environ)
225 def load_sub_configuration(self, filename: StrPath,
226 config: Optional[str] = None) -> Any:
227 """ Load additional configuration from a file. `filename` is the name
228 of the configuration file. The file is first searched in the
229 project directory and then in the global settings directory.
231 If `config` is set, then the name of the configuration file can
232 be additionally given through a .env configuration option. When
233 the option is set, then the file will be exclusively loaded as set:
234 if the name is an absolute path, the file name is taken as is,
235 if the name is relative, it is taken to be relative to the
238 The format of the file is determined from the filename suffix.
239 Currently only files with extension '.yaml' are supported.
241 YAML files support a special '!include' construct. When the
242 directive is given, the value is taken to be a filename, the file
243 is loaded using this function and added at the position in the
246 configfile = self.find_config_file(filename, config)
248 if str(configfile) in CONFIG_CACHE:
249 return CONFIG_CACHE[str(configfile)]
251 if configfile.suffix in ('.yaml', '.yml'):
252 result = self._load_from_yaml(configfile)
253 elif configfile.suffix == '.json':
254 with configfile.open('r', encoding='utf-8') as cfg:
255 result = json.load(cfg)
257 raise UsageError(f"Config file '{configfile}' has unknown format.")
259 CONFIG_CACHE[str(configfile)] = result
263 def load_plugin_module(self, module_name: str, internal_path: str) -> Any:
264 """ Load a Python module as a plugin.
266 The module_name may have three variants:
268 * A name without any '.' is assumed to be an internal module
269 and will be searched relative to `internal_path`.
270 * If the name ends in `.py`, module_name is assumed to be a
271 file name relative to the project directory.
272 * Any other name is assumed to be an absolute module name.
274 In either of the variants the module name must start with a letter.
276 if not module_name or not module_name[0].isidentifier():
277 raise UsageError(f'Invalid module name {module_name}')
279 if '.' not in module_name:
280 module_name = module_name.replace('-', '_')
281 full_module = f'{internal_path}.{module_name}'
282 return sys.modules.get(full_module) or importlib.import_module(full_module)
284 if module_name.endswith('.py'):
285 if self.project_dir is None or not (self.project_dir / module_name).exists():
286 raise UsageError(f"Cannot find module '{module_name}' in project directory.")
288 if module_name in self._private_plugins:
289 return self._private_plugins[module_name]
291 file_path = str(self.project_dir / module_name)
292 spec = importlib.util.spec_from_file_location(module_name, file_path)
294 module = importlib.util.module_from_spec(spec)
295 # Do not add to global modules because there is no standard
296 # module name that Python can resolve.
297 self._private_plugins[module_name] = module
298 assert spec.loader is not None
299 spec.loader.exec_module(module)
303 return sys.modules.get(module_name) or importlib.import_module(module_name)
306 def find_config_file(self, filename: StrPath,
307 config: Optional[str] = None) -> Path:
308 """ Resolve the location of a configuration file given a filename and
309 an optional configuration option with the file name.
310 Raises a UsageError when the file cannot be found or is not
313 if config is not None:
314 cfg_value = getattr(self, config)
316 cfg_filename = Path(cfg_value)
318 if cfg_filename.is_absolute():
319 cfg_filename = cfg_filename.resolve()
321 if not cfg_filename.is_file():
322 LOG.fatal("Cannot find config file '%s'.", cfg_filename)
323 raise UsageError("Config file not found.")
327 filename = cfg_filename
330 search_paths = [self.project_dir, self.config_dir]
331 for path in search_paths:
332 if path is not None and (path / filename).is_file():
333 return path / filename
335 LOG.fatal("Configuration file '%s' not found.\nDirectories searched: %s",
336 filename, search_paths)
337 raise UsageError("Config file not found.")
340 def _load_from_yaml(self, cfgfile: Path) -> Any:
341 """ Load a YAML configuration file. This installs a special handler that
342 allows to include other YAML files using the '!include' operator.
344 yaml.add_constructor('!include', self._yaml_include_representer,
345 Loader=yaml.SafeLoader)
346 return yaml.safe_load(cfgfile.read_text(encoding='utf-8'))
349 def _yaml_include_representer(self, loader: Any, node: yaml.Node) -> Any:
350 """ Handler for the '!include' operator in YAML files.
352 When the filename is relative, then the file is first searched in the
353 project directory and then in the global settings directory.
355 fname = loader.construct_scalar(node)
357 if Path(fname).is_absolute():
358 configfile = Path(fname)
360 configfile = self.find_config_file(loader.construct_scalar(node))
362 if configfile.suffix != '.yaml':
363 LOG.fatal("Format error while reading '%s': only YAML format supported.",
365 raise UsageError("Cannot handle config file format.")
367 return yaml.safe_load(configfile.read_text(encoding='utf-8'))