'''
The ``psi-config`` command: show, read and write psi's settings.
Kept apart from :mod:`psi.application`, which is the experiment launcher
and imports enaml; nothing here needs the GUI machinery, and a script
calling ``psi-config get`` should not pay for loading it.
'''
import argparse
import logging
import os
from pathlib import Path
import psi
from psi import register_defaults
from psi.core.console import setup_windows_console
log = logging.getLogger(__name__)
[docs]
def list_io_templates():
'''
The IO manifest templates `create-io` can copy, shipped with psi.
Those whose name starts with an underscore cannot be run as they are
and must be edited first; all of them can be used as a starting
point.
'''
io_template_path = Path(__file__).parent / 'templates' / 'io'
return list(io_template_path.glob('*.enaml'))
def _io_template_choices():
# The name `create-io` takes for each template, underscore removed.
return {p.stem.strip('_'): p for p in list_io_templates()}
def _render_setting(value, verbose=False):
'''
One line for a setting's value.
Paths print as the path rather than as WindowsPath('...'), and
strings without quotes: this listing is read to check a location
or a device name, and those should be pasteable. Containers are
summarized, because a nested table (cftscal's per-plugin state
runs to dozens of keys) otherwise buries every other setting on
the screen. `verbose` prints them in full.
'''
if isinstance(value, Path):
return str(value)
if isinstance(value, str):
return value if value else '(empty)'
if isinstance(value, bool) or not isinstance(value, (list, tuple, dict)):
return str(value)
if verbose:
return repr(value)
if not value:
return '(none)'
if isinstance(value, dict):
keys = ', '.join(sorted(value))
n = len(value)
return f'({n} {"entry" if n == 1 else "entries"}: {keys})'
if all(isinstance(v, (str, int, float, Path)) for v in value):
return ', '.join(str(v) for v in value)
n = len(value)
return f'({n} {"item" if n == 1 else "items"})'
def _register_downstream_settings():
'''
Register the settings of every installed package that declares some.
A package registers its settings when it is imported, and a tool like
`psi-config` imports only psi. Without this, every CFTSCAL_ or
NOISE_EXP_ key in the configuration file is reported as belonging to
no setting at all -- which is exactly backwards, since those are the
settings somebody running `psi-config` is most likely to be checking.
Discovery is by entry point, so it depends only on what is installed::
[project.entry-points."psi.settings"]
cftscal = "cftscal.config_defaults:DEFAULTS"
Nothing here consults the configuration file, which is the point: a
package declares its settings by being installed, not by being named
somewhere.
Failures are logged rather than raised. `psi-config show` is what
somebody runs when something is already broken, so one package that
will not import must not take the listing down with it.
'''
from importlib.metadata import entry_points
loaded = []
for entry in entry_points(group='psi.settings'):
try:
register_defaults(entry.load())
loaded.append(entry.name)
except Exception as e:
log.warning('Could not register the settings declared by %s, so '
'they will be reported as unknown: %s', entry.name, e)
return loaded
def _group_label(names):
'''
The prefix a group of settings shares.
Taken from the names rather than from a hard-coded list of
packages, since any package can register its own. Segments are
only consumed while every name agrees and never down to a
name's last segment, so NOISE_EXP_* is labelled NOISE_EXP
while a lone CFTS_ROOT is labelled CFTS rather than
CFTS_ROOT.
'''
parts = [n.split('_') for n in names]
common = []
for i in range(min(len(p) - 1 for p in parts)):
segment = {p[i] for p in parts}
if len(segment) != 1:
break
common.append(segment.pop())
return '_'.join(common) or names[0].split('_')[0]
def _format_for_shell(name, value):
'''
Render a setting's value as the one line `psi-config get` prints.
The output is meant to be captured by a script (a Windows batch file's
``for /f``, a shell's ``$(...)``), so it is the bare value in the same
spelling the setting accepts back from the environment or from
`psi-config set`: paths as plain paths, true/false for switches, lists
joined with commas, nothing at all for an unset value.
Raises
------
ValueError
For a table (or a list holding one), which has no one-line form a
script could use.
'''
if value is None:
return ''
# bool before anything numeric: bool is a subclass of int.
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, dict):
raise ValueError(
f'{name} holds a table of values, which has no one-line form. '
'Run `psi-config show --verbose` to see it.')
if isinstance(value, (list, tuple)):
if any(isinstance(v, (dict, list, tuple)) for v in value):
raise ValueError(
f'{name} holds nested values, which have no one-line form. '
'Run `psi-config show --verbose` to see it.')
return ','.join(_format_for_shell(name, v) for v in value)
return str(value)
#: How to point psi at a different configuration file, for --help. Both
#: shells are given because rigs use either, and the per-environment
#: recipes because a separate file per environment is how psi supports
#: more than one configuration on a machine (see psi.config.get_config_file).
CONFIG_FILE_OVERRIDE_HELP = '''\
To use a different configuration file, set the PSI_CONFIG_FILE environment
variable to its path before running psi or psi-config. Leave it unset (or
empty) to use the default, ~/psi/config.toml.
For the current window only (lasts until the window is closed):
Command Prompt: set PSI_CONFIG_FILE=D:\\rig\\config.toml
PowerShell: $env:PSI_CONFIG_FILE = 'D:\\rig\\config.toml'
Permanently, for your Windows user account (every psi install and every
environment; a conda or venv setting below takes precedence once that
environment is activated):
setx PSI_CONFIG_FILE D:\\rig\\config.toml
This affects only windows opened afterwards -- not the one you ran it
in. Add /M (from an administrator window) to set it for all users.
Remove it with `reg delete HKCU\\Environment /v PSI_CONFIG_FILE /f`,
then open a new window. On Linux and macOS, add the `export` line
shown under venv below to ~/.bashrc or ~/.zshrc instead.
For a conda environment (set whenever the environment is activated):
conda activate psi
conda env config vars set PSI_CONFIG_FILE=D:\\rig\\config.toml
conda activate psi
Re-activating is required for it to take effect. Check it with
`conda env config vars list`; remove it with
`conda env config vars unset PSI_CONFIG_FILE`.
For a venv (set whenever the environment is activated): add a line to the
end of the activation script for each shell you use, in the venv's
Scripts folder (bin/activate on Linux and macOS):
Scripts\\activate.bat: set "PSI_CONFIG_FILE=D:\\rig\\config.toml"
Scripts\\Activate.ps1: $env:PSI_CONFIG_FILE = 'D:\\rig\\config.toml'
bin/activate: export PSI_CONFIG_FILE=/data/rig/config.toml
Deactivating the venv does not clear it; close the window or clear it
by hand. Recreating the venv discards these edits.'''
[docs]
def describe_config_file():
'''
Lines saying which configuration file is in use and why.
"Why" is the part that matters: when settings are not what someone
expects, the first thing to rule out is that PSI_CONFIG_FILE (set in
a batch file, a shortcut or a forgotten terminal) is pointing psi at
a different file than the one they have been editing.
'''
config_file = psi.get_config_file()
exists = 'exists' if config_file.exists() else 'does not exist'
lines = [f'Configuration file: {config_file} ({exists})']
override = os.environ.get('PSI_CONFIG_FILE')
if override:
lines.append(' Chosen by the PSI_CONFIG_FILE environment variable, '
'overriding the default (~/psi/config.toml).')
else:
lines.append(' The default location. Set the PSI_CONFIG_FILE '
'environment variable to use another file '
'(see psi-config --help).')
return lines
[docs]
def show_config(args):
loaded = _register_downstream_settings()
for line in describe_config_file():
print(line)
if loaded:
print(f'Also loaded: {", ".join(sorted(loaded))}')
settings = psi.get_all_config()
if not settings:
print('\nNo settings are known.')
return
# A key in the file that no package registered is read by nothing.
# Listing it beside the real settings implies it does something.
known = {n: v for n, v in settings.items()
if psi.get_setting(n) is not None}
unknown = {n: v for n, v in settings.items() if n not in known}
# The source is the whole point of this listing -- "why is this not
# what I put in the file?" -- but most settings are defaults, and
# labelling every one of them just crowds out the few that matter.
labels = {'config file': 'file', 'environment': 'env', 'default': ''}
width = max(len(n) for n in settings)
groups = {}
for name, value in known.items():
groups.setdefault(name.split('_', 1)[0], []).append((name, value))
rendered = {n: _render_setting(v, args.verbose)
for n, v in known.items()}
# Wide enough for most values, but not so wide that one long entry
# pushes the source column off the screen for everything else.
vwidth = min(max((len(v) for v in rendered.values()), default=0), 44)
for key in sorted(groups):
entries = sorted(groups[key])
print(f'\n{_group_label([n for n, _ in entries])}')
for name, _ in entries:
source = labels[psi.config_source(name)]
print(f' {name:<{width}} {rendered[name]:<{vwidth}} '
f'{source}'.rstrip())
doc = psi.get_setting(name).doc
if args.verbose and doc:
print(f' {"":<{width}} {doc}')
if unknown:
print('\nIn the configuration file but not registered by any '
'package loaded here')
for name, value in sorted(unknown.items()):
print(f' {name:<{width}} '
f'{_render_setting(value, args.verbose)}')
print(' (left over from an older version, misspelled, or owned '
'by a package that is')
print(' not installed here)')
print('\nA blank source means the package default.')
if not args.verbose:
print('Run with --verbose to print container values in full.')
[docs]
def get_config_value(args):
# Registered first for the same reason as show and set: without
# the owning package's defaults, a CFTSCAL_ or NOISE_EXP_ setting
# that is not in the file is unknown, and one that is comes back
# uncoerced (a path as a plain string, a number as text).
_register_downstream_settings()
try:
value = psi.get_config(args.setting)
except KeyError:
# The message goes to stderr and the exit status is non-zero, so
# a script that captures stdout gets nothing rather than an
# error message it would take for the value.
raise SystemExit(
f'{args.setting} is not a known setting and is not in the '
f'configuration file ({psi.get_config_file()}).') from None
try:
print(_format_for_shell(args.setting, value))
except ValueError as e:
raise SystemExit(str(e)) from None
[docs]
def set_config_value(args):
# Same reason as show: without the owning package loaded, a
# downstream setting has no registered default, so the value would
# not be coerced to its type and config_source could not report
# that the environment is shadowing the write.
_register_downstream_settings()
# A misspelled name would otherwise be written as a key that
# nothing reads, and reported as a success.
if psi.get_setting(args.setting) is None and not args.force:
import difflib
close = difflib.get_close_matches(
args.setting, psi.setting_names(), n=1)
hint = f' Did you mean {close[0]}?' if close else ''
raise SystemExit(
f'{args.setting} is not a setting any installed package '
f'declares.{hint} Run `psi-config show` for the known '
'settings, or pass --force to write it anyway.')
# A command-line value is a string, and save_config replaces the
# key outright. For a setting that holds a table -- cftscal keeps
# every plugin's saved state in one -- that silently destroys it,
# and the next launch fails reading a str where a dict belongs.
if psi.setting_type(args.setting) is dict:
raise SystemExit(
f'{args.setting} holds a table of values, which cannot be '
'set from the command line -- doing so would replace the '
'whole table. Edit the configuration file directly, or let '
'the application that owns this setting write it.')
# Validate before writing, not after. Writing first and reading
# back second left a rejected value in the file -- and for a path
# setting every later read then raises, so the rig will not start
# and the only way out is hand-editing TOML. The converted value
# is what gets written, so a number, switch or list goes into the
# file as a real TOML number, boolean or array rather than as a
# string that only reads back correctly because it is parsed.
try:
value = psi.parse_setting(args.setting, args.value)
except ValueError as e:
# The message says everything; the ValueError's traceback
# would only bury it.
raise SystemExit(f'{args.setting}: {e}') from None
if isinstance(value, Path):
# Checked, but written as typed: Path() would turn D:/data
# into D:\data in a file the user edits by hand.
value = args.value
psi.save_config({args.setting: value})
source = psi.config_source(args.setting)
value = psi.get_config(args.setting)
if isinstance(value, Path):
value = str(value)
print(f'{args.setting} = {value}')
# Name the file: with PSI_CONFIG_FILE set, it is not the one under
# ~/psi that someone may then open to check the write.
print(f'Written to {psi.get_config_file()}'
+ (' (chosen by PSI_CONFIG_FILE)'
if os.environ.get('PSI_CONFIG_FILE') else ''))
if source == 'environment':
# Writing succeeded but changed nothing the application will
# see. Saying so here avoids a long hunt later.
print(f'WARNING: {args.setting} is also set in the environment, '
'which takes precedence. The value written to the '
'configuration file will not take effect until the '
'environment variable is cleared.')
[docs]
def migrate_config(args):
from psi.config_migrate import migrate
migrate(args.source, dry_run=args.dry_run)
[docs]
def create_config(args):
# create_config_dirs walks the registered settings, so without the
# downstream packages loaded it makes only psi's own directories,
# silently skips CFTSCAL_ROOT and every other root a package
# declares -- on the one command whose whole job is laying out
# the tree for a new rig.
_register_downstream_settings()
base_directory = args.base_directory.rstrip('\\')
psi.create_config(base_directory=base_directory)
if args.base_directory:
psi.create_config_dirs()
[docs]
def create_folders(args):
_register_downstream_settings()
psi.create_config_dirs()
[docs]
def create_io(args):
template = _io_template_choices()[args.template].name
psi.create_io_manifest(template)
[docs]
def build_parser():
parser = argparse.ArgumentParser(
'psi-config',
description='Configure psiexperiment. Run `psi-config show` to see '
'which configuration file is in use.',
epilog=CONFIG_FILE_OVERRIDE_HELP,
# Keep the epilog's line breaks; argparse would otherwise rewrap
# the two example commands into one paragraph.
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(
dest='cmd',
description='Available actions',
)
subparsers.required = True
show = subparsers.add_parser(
'show',
description='Show the config file location (and whether '
'PSI_CONFIG_FILE overrode it), every setting, its '
'resolved value and which layer supplied it.',
epilog=CONFIG_FILE_OVERRIDE_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
show.set_defaults(func=show_config)
show.add_argument(
'--verbose', '-v',
action='store_true',
help='Print container values in full instead of summarizing them.',
)
get_parser = subparsers.add_parser(
'get',
description='Print the resolved value of a setting, and nothing '
'else, for use in scripts. In a Windows batch file: '
'for /f "usebackq delims=" %%i in '
'(`psi-config get PSI_DATA_ROOT`) do set "DATA_ROOT=%%i"',
)
get_parser.set_defaults(func=get_config_value)
get_parser.add_argument('setting', type=str, help='Name of the setting.')
set_parser = subparsers.add_parser(
'set',
description='Write a setting to the configuration file.',
)
set_parser.set_defaults(func=set_config_value)
set_parser.add_argument('setting', type=str, help='Name of the setting.')
set_parser.add_argument('value', type=str, help='Value to write.')
set_parser.add_argument(
'--force',
action='store_true',
help='Write the setting even though no installed package declares '
'it (for one owned by a package not installed here).',
)
migrate = subparsers.add_parser(
'migrate',
description='Convert a pre-rework configuration into config.toml: '
'the legacy config.py, if there is one, and the '
'settings files installed packages kept beside it '
'(such as the workspace.json cftscal kept).',
)
migrate.set_defaults(func=migrate_config)
migrate.add_argument(
'source',
type=Path,
nargs='?',
default=None,
help='The legacy config.py, or the folder holding the legacy files '
'(default: $PSI_CONFIG, or ~/psi). The folder need not have a '
'config.py.',
)
migrate.add_argument(
'--dry-run',
action='store_true',
help='Print what would be written without writing it.',
)
create = subparsers.add_parser('create')
create.set_defaults(func=create_config)
create.add_argument(
'--base-directory',
type=str,
help='Root directory to store data and settings for psiexperiment.'
)
make = subparsers.add_parser(
'create-folders',
description='Create folders defined in the config file.',
)
make.set_defaults(func=create_folders)
io = subparsers.add_parser(
'create-io',
description='Creates a hardware configuration skeleton that you can edit'
)
io.set_defaults(func=create_io)
io.add_argument(
'template',
type=str,
choices=sorted(_io_template_choices()),
help='Template to use for hardware configuration skeleton.',
)
return parser
[docs]
def main():
setup_windows_console()
args = build_parser().parse_args()
args.func(args)