Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 135 additions & 14 deletions psycodict/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,52 @@ def strbool(s):
raise ValueError(s)


def psycodict_home():
"""
The directory used for psycodict's own files (the configuration file and
the slow-query log) when no explicit location is given: ``~/.psycodict``.

Raises ``RuntimeError`` when no home directory can be determined -- for
example a container running with ``HOME`` unset and a uid that has no
passwd entry, where ``expanduser`` returns the literal ``~``. Refusing
loudly beats quietly creating a directory named ``~`` in the working
directory.
"""
home = os.path.expanduser("~")
if not os.path.isabs(home):
raise RuntimeError(
"Cannot determine a home directory for psycodict's configuration; "
"set the PSYCODICT_CONFIG environment variable (or pass "
"config_file) to choose a location for the configuration file"
)
return os.path.join(home, ".psycodict")


def find_config_file():
"""
The path of the configuration file when none is specified explicitly.

This is the first of:

- the ``PSYCODICT_CONFIG`` environment variable;
- ``config.ini`` in the current directory, if it exists;
- ``config.ini`` in ``~/.psycodict``.

A missing configuration file is created at the resolved location. The
current-directory candidate requires the file to already exist, so
nothing is ever created in the working directory: a fresh setup gets its
configuration under ``~/.psycodict`` (or wherever ``PSYCODICT_CONFIG``
points). When no home directory can be determined either, this raises
``RuntimeError`` (see :func:`psycodict_home`).
"""
path = os.environ.get("PSYCODICT_CONFIG")
if path:
return os.path.abspath(os.path.expanduser(path))
if os.path.exists("config.ini"):
return os.path.abspath("config.ini")
return os.path.join(psycodict_home(), "config.ini")


class Configuration():
"""
This configuration object merges input from the command line and a configuration file.
Expand All @@ -30,21 +76,33 @@ class Configuration():

- ``parser`` -- an argparse.ArgumentParser instance. If not provided, a default will be created.
- ``defaults`` -- a dictionary with default values for the created argument parser. Only used if a parser is not specified. The keys used are:
- ``config_file`` -- the filename for the configuration file
- ``config_file`` -- the filename for the configuration file. If not
given, it is discovered: the ``PSYCODICT_CONFIG`` environment
variable, then ``config.ini`` in the current directory if it exists,
then ``~/.psycodict/config.ini`` (created on first use); see
:func:`find_config_file`.
- ``secrets_file`` -- the filename for the secrets file, whose values
override the configuration file. If not given, ``secrets.ini`` next
to the configuration file.
- ``logging_slowcutoff`` -- a float, giving the threshold above which a slow-query warning will be logged
- ``logging_slowlogfile`` -- a filename where slow-query warnings are printed
- ``logging_slowlogfile`` -- a filename where slow-query warnings are
printed. The default value ``slow_queries.log`` is placed in a
``logs`` directory next to the configuration file, falling back to
``~/.psycodict/logs`` when that location is not writable; any other
value is used verbatim.
- ``postgresql_host`` -- the hostname for the database
- ``postgresql_port`` -- an integer, the port to use when connecting to the database
- ``postgresql_user`` -- the username when connecting to the database
- ``postgresql_password`` -- the password for connecting to the database
- ``writeargstofile`` - a boolean, if config file doesn't exist, it determines if command line arguments are written to the config file instead of the default arguments
- ``readargs`` - a boolean, if determines if command line arguments are read
- ``postgresql_dbname`` -- the name of the database to connect to
- ``writeargstofile`` -- a boolean, if config file doesn't exist, it determines if command line arguments are written to the config file instead of the default arguments
- ``readargs`` -- a boolean (default False), determining whether command
line arguments are read. Leave this off in libraries and applications
with their own command line; pass True in a script whose command line
psycodict should parse.
"""
def __init__(self, parser=None, defaults={}, writeargstofile=False, readargs=None):
if readargs is None:
import __main__ as main
# if a file was ran
readargs = hasattr(main, '__file__')
def __init__(self, parser=None, defaults={}, writeargstofile=False, readargs=False):
builtin_parser = parser is None

if parser is None:
parser = argparse.ArgumentParser(description="Default psycodict parser")
Expand All @@ -54,16 +112,17 @@ def __init__(self, parser=None, defaults={}, writeargstofile=False, readargs=Non
"--config-file",
dest="config_file",
metavar="FILE",
help="configuration file [default: %(default)s]",
default=defaults.get("config_file", "config.ini"),
help="configuration file [default: $PSYCODICT_CONFIG, "
"./config.ini if present, else ~/.psycodict/config.ini]",
default=defaults.get("config_file"),
)
parser.add_argument(
"-s",
"--secrets-file",
dest="secrets_file",
metavar="SECRETS",
help="secrets file [default: %(default)s]",
default=defaults.get("secrets_file", "secrets.ini"),
help="secrets file [default: secrets.ini next to the configuration file]",
default=defaults.get("secrets_file"),
)

logginggroup = parser.add_argument_group("Logging options:")
Expand Down Expand Up @@ -123,7 +182,7 @@ def __init__(self, parser=None, defaults={}, writeargstofile=False, readargs=Non
dest="postgresql_dbname",
metavar="DBNAME",
help="PostgreSQL database name [default: %(default)s]",
default="lmfdb",
default=defaults.get("postgresql_dbname", "lmfdb"),
)

def sec_opt(key):
Expand All @@ -141,6 +200,19 @@ def sec_opt(key):
# only read config file
args = parser.parse_args([])

# Resolve the file locations. An explicit location (from the
# defaults dictionary or the command line) is used verbatim; with the
# builtin parser both default to None, triggering the discovery in
# find_config_file -- so a missing configuration file is created
# under ~/.psycodict (or $PSYCODICT_CONFIG), never in the working
# directory.
if args.config_file is None:
args.config_file = find_config_file()
if args.secrets_file is None:
args.secrets_file = os.path.join(
os.path.dirname(os.path.abspath(args.config_file)), "secrets.ini"
)

args_dict = vars(args)
default_arguments_dict = vars(parser.parse_args([]))

Expand Down Expand Up @@ -179,6 +251,11 @@ def sec_opt(key):
for opt, val in options.items():
_cfgp.set(sec, opt, str(val))

# the resolved location may sit in a directory that does not
# exist yet (a fresh ~/.psycodict in particular)
confdir = os.path.dirname(args.config_file)
if confdir:
os.makedirs(confdir, exist_ok=True)
with open(args.config_file, "w") as configfile:
_cfgp.write(configfile)

Expand Down Expand Up @@ -238,6 +315,50 @@ def get(section, key):
if key not in default_arguments_dict:
self.extra_options[key] = val

if builtin_parser:
# The default value of slowlogfile used to be created in the
# working directory; place it in a logs directory next to the
# configuration file instead, falling back to ~/.psycodict/logs
# when that location cannot be written (a system-managed
# configuration in a read-only directory, say -- reading a
# configuration must not require write access beside it). Any
# other value is used verbatim. (Callers supplying their own
# parser own their logging semantics; they are not touched.)
logopts = self.options["logging"]
if logopts.get("slowlogfile") == "slow_queries.log":
logdir = self._default_log_dir(args.config_file)
if logdir is not None:
logopts["slowlogfile"] = os.path.join(logdir, "slow_queries.log")

@staticmethod
def _default_log_dir(config_file):
"""
The directory for the default slow-query log: the first of
``<config dir>/logs`` and ``~/.psycodict/logs`` that exists or can be
created, and is writable.

The directory must exist and be writable before PostgresDatabase
attaches its FileHandler, so the candidates are probed by creating
them. Returns None when neither candidate is usable (the plain
default name is then kept, preserving the historical behavior of
writing in the working directory).
"""
candidates = [
os.path.join(os.path.dirname(os.path.abspath(config_file)), "logs")
]
try:
candidates.append(os.path.join(psycodict_home(), "logs"))
except RuntimeError:
pass
for candidate in candidates:
try:
os.makedirs(candidate, exist_ok=True)
except OSError:
continue
if os.access(candidate, os.W_OK | os.X_OK):
return candidate
return None

def get_postgresql_default(self):
res = dict(self.default_args["postgresql"])
res["port"] = int(res["port"])
Expand Down
Loading