Source code for sciris.sc_settings

"""
Define options for Sciris, mostly plotting options.

All options should be set using ``set()`` or directly, e.g.::

    sc.options(font_size=18)

To reset default options, use::

    sc.options.reset()

Note: "options" is used to refer to the choices available (e.g., DPI), while "settings"
is used to refer to the choices made (e.g., ``dpi=150``).
"""

import os
import re
import inspect
import warnings
import collections as co
import pylab as pl
from . import sc_utils as scu
from . import sc_odict as sco
from . import sc_printing as scp


__all__ = ['style_simple', 'style_fancy', 'ScirisOptions', 'options', 'parse_env', 'help']

#%% Define plotting options
# See also simple.mplstyle and fancy.mplstyle files, which can be generated by:
#   sc.saveyaml('simple.mplstyle', sc.sc_settings.style_simple)
#   sc.saveyaml('fancy.mplstyle',  sc.sc_settings.style_fancy)

# Define simple plotting options -- similar to Matplotlib default
style_simple = {
    'axes.axisbelow':    True, # So grids show up behind
    'axes.spines.right': False,
    'axes.spines.top':   False,
    'figure.facecolor':  'white',
    'font.family':       'sans-serif', # Replaced with Mulish in load_fonts() if import succeeds
    'legend.frameon':    False,
}

# Define default plotting options -- loosely inspired by Seaborn
style_fancy = scu.dcp(style_simple)
style_fancy.update({
    'axes.facecolor': '#f2f2ff',
    'axes.grid':      True,
    'grid.color':     'white',
    'grid.linewidth':  1,
    'lines.linewidth': 2,
})


[docs] def parse_env(var, default=None, which='str'): """ Simple function to parse environment variables Args: var (str): name of the environment variable to get default (any): default value which (str): what type to convert to (if None, don't convert) *New in version 2.0.0.* """ val = os.getenv(var, default) if which is None: return val elif which in ['str', 'string']: if val: out = str(val) else: out = '' elif which == 'int': if val: out = int(val) else: out = 0 elif which == 'float': if val: out = float(val) else: out = 0.0 elif which == 'bool': if val: if isinstance(val, str): if val.lower() in ['false', 'f', '0', '', 'none']: val = False else: val = True out = bool(val) else: out = False else: # pragma: no cover errormsg = f'Could not understand type "{which}": must be None, str, int, float, or bool' raise ValueError(errormsg) return out
#%% Define the options class
[docs] class ScirisOptions(sco.objdict): """ Set options for Sciris. Note: this class should not be invoked directly. An instance is created automatically, which is the accessible via ``sc.options``. Use :meth:`sc.options.reset() <ScirisOptions.reset>` to reset all values to default, or :meth:`sc.options.set(dpi='default') <ScirisOptions.set>` to reset one parameter to default. See :meth:`sc.options.help(detailed=True) <ScirisOptions.help>` for more information. Options can also be saved and loaded using :meth:`sc.options.save() <ScirisOptions.save>` and :meth:`sc.options.load() <ScirisOptions.load>`. See :meth:`sc.options.with_style() <ScirisOptions.with_style>` to set options temporarily. Common options are (see also :meth:`sc.options.help(detailed=True) <ScirisOptions.help>`): - dpi: the overall DPI (i.e. size) of the figures - font: the font family/face used for the plots - fontsize: the font size used for the plots - backend: which Matplotlib backend to use - interactive: convenience method to set backend - jupyter: True or False; set defaults for Jupyter (change backend) - style: the plotting style to use (choices are 'simple' or 'fancy') Each setting can also be set with an environment variable, e.g. SCIRIS_DPI. Note also the environment variable SCIRIS_LAZY, which imports Sciris lazily (i.e. does not import submodules). **Examples**:: sc.options(dpi=150) # Larger size sc.options(style='simple', font='Rosario') # Change to the "simple" Sciris style with a custom font sc.options.set(fontsize=18, show=False, backend='agg', precision=64) # Multiple changes sc.options(interactive=False) # Turn off interactive plots sc.options(jupyter=True) # Defaults for Jupyter sc.options('defaults') # Reset to default options | *New in version 1.3.0.* | *New in version 2.0.0:* revamped with additional options ``interactive`` and ``jupyter``, plus styles | *New in version 3.0.0:* renamed from Options to ScirisOptions to avoid potential confusion with ``sc.options`` """ def __init__(self): super().__init__() optdesc, options = self.get_orig_options() # Get the options self.update(options) # Update this object with them self.optdesc = optdesc # Set the description as an attribute, not a dict entry self.orig_options = scu.dcp(options) # Copy the default options return
[docs] def __call__(self, *args, **kwargs): """Allow ``sc.options(dpi=150)`` instead of ``sc.options.set(dpi=150)`` """ return self.set(*args, **kwargs)
[docs] def to_dict(self): """ Pull out only the settings from the options object """ return {k:v for k,v in self.items()}
def __repr__(self): """ Brief representation """ output = scp.objectid(self) output += 'Sciris options (see also sc.options.disp()):\n' output += scu.pp(self.to_dict(), output=True) return output def __enter__(self): """ Allow to be used in a with block """ return self def __exit__(self, *args, **kwargs): """ Allow to be used in a with block """ try: reset = {} for k,v in self.on_entry.items(): if self[k] != v: # Only reset settings that have changed reset[k] = v self.set(**reset) del self.on_entry except AttributeError as E: # pragma: no cover errormsg = 'Please use sc.options.context() if using a with block' raise AttributeError(errormsg) from E return
[docs] def disp(self): """ Detailed representation """ output = 'Sciris options (see also sc.options.help()):\n' keylen = 14 # Maximum key length -- "numba_parallel" for k,v in self.items(): keystr = scp.colorize(f' {k:>{keylen}s}: ', fg='cyan', output=True) reprstr = scu.pp(v, output=True) reprstr = scp.indent(n=keylen+4, text=reprstr, width=None) output += f'{keystr}{reprstr}' print(output) return
[docs] @staticmethod def get_orig_options(): """ Set the default options for Sciris -- not to be called by the user, use :meth:`sc.options.set('defaults') <ScirisOptions.set>` instead. """ # Options acts like a class, but is actually an objdict for simplicity optdesc = sco.objdict() # Help for the options options = sco.objdict() # The options optdesc.sep = 'Set thousands seperator' options.sep = parse_env('SCIRIS_SEP', ',', 'str') optdesc.aspath = 'Set whether to return Path objects instead of strings by default' options.aspath = parse_env('SCIRIS_ASPATH', False, 'bool') optdesc.style = 'Set the default plotting style -- options are "default", "simple", and "fancy", plus those in pl.style.available; see also options.rc' options.style = parse_env('SCIRIS_STYLE', 'default', 'str') optdesc.dpi = 'Set the default DPI -- the larger this is, the larger the figures will be' options.dpi = parse_env('SCIRIS_DPI', pl.rcParams['figure.dpi'], 'int') optdesc.font = 'Set the default font family (e.g., sans-serif or Arial)' options.font = parse_env('SCIRIS_FONT', pl.rcParams['font.family'], None) # Can be a string or list, so don't cast it to any object optdesc.fontsize = 'Set the default font size' options.fontsize = parse_env('SCIRIS_FONT_SIZE', pl.rcParams['font.size'], 'str') optdesc.interactive = 'Convenience method to set figure backend' options.interactive = parse_env('SCIRIS_INTERACTIVE', True, 'bool') optdesc.jupyter = 'Convenience method to set common settings for Jupyter notebooks: set to "auto" (which detects if Jupyter is running), "retina", "default" (or empty, which use regular PNG output), or "widget" to set backend' options.jupyter = parse_env('SCIRIS_JUPYTER', 'auto', 'str') optdesc.backend = 'Set the Matplotlib backend (use "agg" for non-interactive)' options.backend = parse_env('SCIRIS_BACKEND', '', 'str') # Unfortunately pl.get_backend() creates the backend if it doesn't exist, which can be extremely slow optdesc.rc = 'Matplotlib rc (run control) style parameters used during plotting -- usually set automatically by "style" option' options.rc = {} return optdesc, options
[docs] def set(self, key=None, value=None, use=True, **kwargs): """ Actually change the style. See :meth:`sc.options.help() <ScirisOptions.help>` for more information. Args: key (str): the parameter to modify, or 'defaults' to reset everything to default values value (varies): the value to specify; use None or 'default' to reset to default use (bool): whether to immediately apply the change (to Matplotlib) kwargs (dict): if supplied, set multiple key-value pairs **Example**:: sc.options.set(dpi=50) # Equivalent to sc.options(dpi=50) """ # Reset to defaults if key in ['default', 'defaults']: kwargs = self.orig_options # Reset everything to default # Handle other keys elif key is not None: kwargs.update({key:value}) # Handle Jupyter self.set_jupyter(kwargs) # Handle interactivity if 'interactive' in kwargs.keys(): interactive = kwargs['interactive'] if interactive in [None, 'default']: interactive = self.orig_options['interactive'] if interactive: kwargs['backend'] = self.orig_options['backend'] else: kwargs['backend'] = 'agg' # Reset options for key,value in kwargs.items(): # Handle deprecations rename = {'font_size': 'fontsize', 'font_family':'font'} if key in rename.keys(): # pragma: no cover oldkey = key key = rename[oldkey] if key not in self.keys(): # pragma: no cover keylist = self.orig_options.keys() keys = '\n'.join(keylist) errormsg = f'Option "{key}" not recognized; options are "defaults" or:\n{keys}\n\nSee help(sc.options.set) for more information.' raise ValueError(errormsg) from KeyError(key) # Can't use sc.KeyNotFoundError since would be a circular import else: if value in [None, 'default']: value = self.orig_options[key] self[key] = value matplotlib_keys = ['fontsize', 'font', 'dpi', 'backend'] if key in matplotlib_keys: self.set_matplotlib_global(key, value) if use: self.use_style() return
[docs] def reset(self): """ Alias to sc.options.set('defaults') *New in version 3.1.0.* """ self.set('defaults')
[docs] def context(self, **kwargs): """ Alias to set() for non-plotting options, for use in a "with" block. Note: for plotting options, use :meth:`sc.options.with_style() <ScirisOptions.with_style>`, which is linked to Matplotlib's context manager. If you set plotting options with this, they won't have any effect. """ # Store current settings self.on_entry = {k:self[k] for k in kwargs.keys()} # Make changes self.set(**kwargs) return self
[docs] def set_matplotlib_global(self, key, value): """ Set a global option for Matplotlib -- not for users """ if value: # Don't try to reset any of these to a None value if key == 'fontsize': pl.rcParams['font.size'] = value elif key == 'font': pl.rcParams['font.family'] = value elif key == 'dpi': pl.rcParams['figure.dpi'] = value elif key == 'backend': # Before switching the backend, ensure the default value has been populated -- located here since slow if called on import if not self.orig_options['backend']: self.orig_options['backend'] = pl.get_backend() pl.switch_backend(value) else: raise KeyError(f'Key {key} not found') return
[docs] def set_jupyter(self, kwargs=None): """ Handle Jupyter settings """ if kwargs is None: # Default setting kwargs = dict(jupyter=self['jupyter']) if scu.isjupyter() and 'jupyter' in kwargs.keys(): # pragma: no cover # Handle import try: from IPython import get_ipython import matplotlib_inline magic = get_ipython().run_line_magic except Exception as E: warnmsg = f'Could not import IPython and matplotlib_inline; not attempting to set Jupyter ({str(E)})' warnings.warn(warnmsg, category=UserWarning, stacklevel=2) magic = None # Import succeeded if magic: # Handle options widget_opts = ['widget', 'matplotlib', 'interactive'] retina_opts = [True, 'True', 'auto', 'retina'] default_opts = [None, False, '', 'False', 'default'] format_opts = ['retina', 'pdf','png','png2x','svg','jpg'] jupyter = kwargs['jupyter'] if jupyter in widget_opts: jupyter = 'widget' elif jupyter in retina_opts: jupyter = 'retina' elif jupyter in default_opts: jupyter = 'png' if jupyter == 'widget': try: # First try interactive with scp.capture() as stderr: # Hack since this outputs text rather an actual warning magic('matplotlib', 'widget') assert 'Warning' not in stderr, stderr except Exception as E: warnmsg = f'Could not set backend to "widget" (error: "{E}"); try "pip install ipympl". Defaulting to "retina" instead' warnings.warn(warnmsg, category=UserWarning, stacklevel=2) jupyter = 'retina' if jupyter in format_opts: magic('matplotlib', 'inline') matplotlib_inline.backend_inline.set_matplotlib_formats(jupyter) else: errormsg = f'Could not understand Jupyter option "{jupyter}": options are widget, {scu.strjoin(format_opts)}' raise ValueError(errormsg) return
[docs] def get_default(self, key): """ Helper function to get the original default options """ return self.orig_options[key]
[docs] def changed(self, key): """ Check if current setting has been changed from default """ if key in self.orig_options: return self[key] != self.orig_options[key] else: return None
[docs] def help(self, detailed=False, output=False): """ Print information about options. Args: detailed (bool): whether to print out full help output (bool): whether to return a list of the options **Example**:: sc.options.help(detailed=True) """ # If not detailed, just print the docstring for sc.options if not detailed: print(self.__doc__) return n = 15 # Size of indent optdict = sco.objdict() for key in self.orig_options.keys(): entry = sco.objdict() entry.key = key entry.current = scp.indent(n=n, width=None, text=scu.pp(self[key], output=True)).rstrip() entry.default = scp.indent(n=n, width=None, text=scu.pp(self.orig_options[key], output=True)).rstrip() if not key.startswith('rc'): entry.variable = f'SCIRIS_{key.upper()}' # NB, hard-coded above! else: entry.variable = 'No environment variable' entry.desc = scp.indent(n=n, text=self.optdesc[key]) optdict[key] = entry # Convert to a dataframe for nice printing print('Sciris global options ("Environment" = name of corresponding environment variable):') for k, key, entry in optdict.enumitems(): scp.heading(f'{k}. {key}', spaces=0, spacesafter=0) changestr = '' if entry.current == entry.default else ' (modified)' print(f' Key: {key}') print(f' Current: {entry.current}{changestr}') print(f' Default: {entry.default}') print(f' Environment: {entry.variable}') print(f' Description: {entry.desc}') scp.heading('Methods:', spacesafter=0) print(""" sc.options(key=value) -- set key to value sc.options[key] -- get or set key sc.options.set() -- set option(s) sc.options.get_default() -- get default setting(s) sc.options.load() -- load settings from file sc.options.save() -- save settings to file sc.options.to_dict() -- convert to dictionary sc.options.with_style() -- create style context for plotting """) if output: return optdict else: return
[docs] def load(self, filename, verbose=True, **kwargs): """ Load current settings from a JSON file. Args: filename (str): file to load kwargs (dict): passed to :func:`sc.loadjson() <sciris.sc_fileio.loadjson>` """ from . import sc_fileio as scf # To avoid circular import json = scf.loadjson(filename=filename, **kwargs) current = self.to_dict() new = {k:v for k,v in json.items() if v != current[k]} # Don't reset keys that haven't changed self.set(**new) if verbose: print(f'Settings loaded from {filename}') return
[docs] def save(self, filename, verbose=True, **kwargs): """ Save current settings as a JSON file. Args: filename (str): file to save to kwargs (dict): passed to :func:`sc.savejson() <sciris.sc_fileio.savejson>` """ from . import sc_fileio as scf # To avoid circular import json = self.to_dict() output = scf.savejson(filename=filename, obj=json, **kwargs) if verbose: print(f'Settings saved to {filename}') return output
def _handle_style(self, style=None, reset=False, copy=True): """ Helper function to handle logic for different styles """ rc = self.rc # By default, use current if isinstance(style, dict): # If an rc-like object is supplied directly # pragma: no cover rc = scu.dcp(style) elif style is not None: # Usual use case stylestr = str(style).lower() if stylestr in ['default', 'matplotlib', 'reset']: pl.style.use('default') # Need special handling here since not in pl.style.library...ugh rc = {} elif stylestr in ['simple', 'sciris']: rc = scu.dcp(style_simple) elif stylestr in ['fancy', 'covasim']: rc = scu.dcp(style_fancy) elif style in pl.style.library: rc = scu.dcp(pl.style.library[style]) else: # pragma: no cover errormsg = f'Style "{style}"; not found; options are "default", "simple", "fancy", plus:\n{scu.newlinejoin(pl.style.available)}' raise ValueError(errormsg) if reset: # pragma: no cover self.rc = rc if copy: rc = scu.dcp(rc) return rc
[docs] def with_style(self, style=None, use=False, **kwargs): """ Combine all Matplotlib style information, and either apply it directly or create a style context. To set globally, use :meth:`sc.options.use_style() <ScirisOptions.use_style>`. Otherwise, use :meth:`sc.options.with_style() <ScirisOptions.with_style>` as part of a ``with`` block to set the style just for that block (using this function outsde of a with block and with ``use=False`` has no effect, so don't do that!). Note: you can also just use :func:`pl.style.context() <matplotlib.style.context>`. Args: style_args (dict): a dictionary of style arguments use (bool): whether to set as the global style; else, treat as context for use with "with" (default) kwargs (dict): additional style arguments Valid style arguments are: - ``dpi``: the figure DPI - ``font``: font (typeface) - ``fontsize``: font size - ``grid``: whether or not to plot gridlines - ``facecolor``: color of the axes behind the plot - any of the entries in :class:`pl.rcParams <matplotlib.RcParams>` **Examples**:: with sc.options.with_style(dpi=300): # Use default options, but higher DPI pl.figure() pl.plot([1,3,6]) with sc.options.with_style(style='fancy'): # Use the "fancy" style pl.figure() pl.plot([6,1,3]) """ # Handle inputs rc = scu.dcp(self.rc) # Make a local copy of the currently used settings if isinstance(style, dict): # pragma: no cover style_args = style style = None else: kwargs['style'] = style # Store here to be used just below style_args = None kwargs = scu.mergedicts(style_args, kwargs) # Handle style, overwiting existing style = kwargs.pop('style', self.style) rc = self._handle_style(style, reset=False) def pop_keywords(sourcekeys, rckey): """ Helper function to handle input arguments """ sourcekeys = scu.tolist(sourcekeys) key = sourcekeys[0] # Main key value = None changed = self.changed(key) if changed: value = self[key] for k in sourcekeys: kwvalue = kwargs.pop(k, None) if kwvalue is not None: value = kwvalue if value is not None: rc[rckey] = value return # Handle special cases pop_keywords('dpi', rckey='figure.dpi') pop_keywords(['font', 'fontfamily', 'font_family'], rckey='font.family') pop_keywords(['fontsize', 'font_size'], rckey='font.size') pop_keywords('grid', rckey='axes.grid') pop_keywords('facecolor', rckey='axes.facecolor') # Handle other keywords for key,value in kwargs.items(): if key not in pl.rcParams: errormsg = f'Key "{key}" does not match any value in Sciris options or pl.rcParams' raise KeyError(errormsg) elif value is not None: rc[key] = value # Tidy up if use: return pl.style.use(scu.dcp(rc)) else: return pl.style.context(scu.dcp(rc))
[docs] def use_style(self, **kwargs): """ Shortcut to set Sciris's current style as the global default. **Example**:: sc.options.use_style() # Set Sciris options as default pl.figure() pl.plot([1,3,7]) pl.style.use('seaborn-whitegrid') # to something else pl.figure() pl.plot([3,1,4]) """ return self.with_style(use=True, **kwargs)
# Create the options on module load options = ScirisOptions() #%% Module help
[docs] def help(pattern=None, source=False, ignorecase=True, flags=None, context=False, output=False, debug=False): """ Get help on Sciris in general, or search for a word/expression. Args: pattern (str): the word, phrase, or regex to search for source (bool): whether to search source code instead of docstrings for matches ignorecase (bool): whether to ignore case (equivalent to ``flags=re.I``) flags (list): additional flags to pass to :func:`re.findall()` context (bool): whether to show the line(s) of matches output (bool): whether to return the dictionary of matches **Examples**:: sc.help() sc.help('smooth') sc.help('JSON', ignorecase=False, context=True) sc.help('pickle', source=True, context=True) | *New in version 1.3.0.* | *New in version 1.3.1:* "source" argument """ defaultmsg = ''' For general help using Sciris, the best place to start is the docs: http://docs.sciris.org To search for a keyword/phrase/regex in Sciris' docstrings, use e.g.: >>> sc.help('smooth') See help(sc.help) for more information. ''' # No pattern is provided, print out default help message if pattern is None: print(defaultmsg) else: import sciris as sc # Here to avoid circular import # Handle inputs flags = sc.tolist(flags) if ignorecase: flags.append(re.I) def func_ok(f): """ Skip certain functions """ excludes = [ f.startswith('_'), # These are private f.startswith('sc_'), # These are modules f in ['help', 'options', 'extras'], # These are self-referential f in ['style_simple', 'style_fancy'], # These are just dicts ] ok = not(any(excludes)) return ok # Get available functions/classes funcs = [f for f in dir(sc) if func_ok(f)] # Skip dunder methods and modules # Get docstrings or full source code docstrings = dict() for funcname in funcs: try: f = getattr(sc, funcname) if source: string = inspect.getsource(f) else: string = f.__doc__ docstrings[funcname] = string except OSError as E: # Happens for built-ins, e.g. defaultdict if debug: errormsg = f'sc.help(): Encountered an error on {funcname}: {E}' print(errormsg) # Find matches matches = co.defaultdict(list) linenos = co.defaultdict(list) for k,docstring in docstrings.items(): if docstring: for l,line in enumerate(docstring.splitlines()): if re.findall(pattern, line, *flags): linenos[k].append(str(l)) matches[k].append(line) elif debug: errormsg = f'sc.help(): No docstring for {k}' print(errormsg) # Assemble output if not len(matches): # pragma: no cover string = f'No matches for "{pattern}" found among {len(docstrings)} available functions.' else: string = f'Found {len(matches)} matches for "{pattern}" among {len(docstrings)} available functions:\n' maxkeylen = 0 for k in matches.keys(): maxkeylen = max(len(k), maxkeylen) for k,match in matches.items(): if not context: keystr = f' {k:>{maxkeylen}s}' else: keystr = k matchstr = f'{keystr}: {len(match)} matches' if context: matchstr = sc.heading(matchstr, output=True) else: matchstr += '\n' string += matchstr if context: lineno = linenos[k] maxlnolen = max([len(l) for l in lineno]) for l,m in zip(lineno, match): string += sc.colorize(string=f' {l:>{maxlnolen}s}: ', fg='cyan', output=True) string += f'{m}\n' string += '—'*60 + '\n' # Print result and return print(string) if output: return string else: return