"""
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 matplotlib.pyplot as plt
import sciris as sc
__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)
# Matplotlib defaults (for resetting)
style_default = {
'axes.axisbelow': 'line',
'axes.spines.right': True,
'axes.spines.top': True,
'figure.facecolor': 'white',
'font.family': ['sans-serif'],
'legend.frameon': True,
'axes.facecolor': 'white',
'axes.grid': False,
'grid.color': '#b0b0b0',
'grid.linewidth': 0.8,
'lines.linewidth': 1.5,
}
# 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 = sc.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(sc.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 = sc.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 = sc.objectid(self)
output += 'Sciris options (see also sc.options.disp()):\n'
output += sc.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 = sc.colorize(f' {k:>{keylen}s}: ', fg='cyan', output=True)
reprstr = sc.pp(v, output=True)
reprstr = sc.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 = sc.objdict() # Help for the options
options = sc.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 plt.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', plt.rcParams['figure.dpi'], 'int')
optdesc.font = 'Set the default font family (e.g., sans-serif or Arial)'
options.font = parse_env('SCIRIS_FONT', plt.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', plt.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 plt.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(style=kwargs.get('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': plt.rcParams['font.size'] = value
elif key == 'font': plt.rcParams['font.family'] = value
elif key == 'dpi': plt.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'] = plt.get_backend()
plt.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 sc.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 sc.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, {sc.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 = sc.objdict()
for key in self.orig_options.keys():
entry = sc.objdict()
entry.key = key
entry.current = sc.indent(n=n, width=None, text=sc.pp(self[key], output=True)).rstrip()
entry.default = sc.indent(n=n, width=None, text=sc.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 = sc.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():
sc.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}')
sc.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>`
"""
json = sc.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>`
"""
json = self.to_dict()
output = sc.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.update(style)
elif style is not None: # Usual use case, a string is supplied
stylestr = str(style).lower()
rc = sc.dcp(style_default)
if stylestr in ['default', 'matplotlib', 'reset']:
pass
elif stylestr in ['simple', 'sciris']:
rc.update(style_simple)
elif stylestr in ['fancy', 'covasim']:
rc.update(style_fancy)
elif style in plt.style.library:
rc.update(plt.style.library[style])
else: # pragma: no cover
errormsg = f'Style "{style}"; not found; options are "default", "simple", "fancy", plus:\n{sc.newlinejoin(plt.style.available)}'
raise ValueError(errormsg)
if reset: # pragma: no cover
self.rc = rc
if copy:
rc = sc.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:`plt.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:`plt.rcParams <matplotlib.RcParams>`
**Examples**::
with sc.options.with_style(dpi=300): # Use default options, but higher DPI
plt.figure()
plt.plot([1,3,6])
with sc.options.with_style(style='fancy'): # Use the "fancy" style
plt.figure()
plt.plot([6,1,3])
"""
# Handle inputs
rc = sc.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 = sc.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 = sc.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 plt.rcParams:
errormsg = f'Key "{key}" does not match any value in Sciris options or plt.rcParams'
raise KeyError(errormsg)
elif value is not None:
rc[key] = value
# Tidy up
if use:
return plt.style.use(sc.dcp(rc))
else:
return plt.style.context(sc.dcp(rc))
[docs]
def use_style(self, style=None, **kwargs):
"""
Shortcut to set Sciris's current style as the global default.
**Example**::
sc.options.use_style() # Set Sciris options as default
plt.figure()
plt.plot([1,3,7])
plt.style.use('ggplot') # to something else
plt.figure()
plt.plot([3,1,4])
"""
return self.with_style(style=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