Skip to content

Commit

Permalink
Merge cf8da32 into 5b794f2
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 committed Feb 25, 2021
2 parents 5b794f2 + cf8da32 commit 7b445ed
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 6 deletions.
6 changes: 6 additions & 0 deletions magicgui/_magicgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def magicgui(
result_widget: bool = False,
main_window: bool = False,
app: AppRef = None,
persist: bool = False,
**param_options: dict,
):
"""Return a :class:`FunctionGui` for ``function``.
Expand Down Expand Up @@ -53,6 +54,10 @@ def magicgui(
by default True.
app : magicgui.Application or str, optional
A backend to use, by default ``None`` (use the default backend.)
persist : bool, optional
If `True`, when parameter values change in the widget, they will be stored to
disk (in `~/.config/magicgui/cache`) and restored when the widget is loaded
again with ``persist = True``. By default, `False`.
**param_options : dict of dict
Any additional keyword arguments will be used as parameter-specific options.
Expand Down Expand Up @@ -93,6 +98,7 @@ def magic_factory(
result_widget: bool = False,
main_window: bool = False,
app: AppRef = None,
persist: bool = False,
widget_init: Callable[[FunctionGui], None] | None = None,
**param_options: dict,
):
Expand Down
4 changes: 4 additions & 0 deletions magicgui/_magicgui.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def magicgui( # noqa
result_widget: bool = False,
main_window: Literal[False] = False,
app: AppRef = None,
persist: bool = False,
**param_options: dict,
) -> FunctionGui[_R]: ...
@overload # noqa: E302
Expand All @@ -47,6 +48,7 @@ def magicgui( # noqa
result_widget: bool = False,
main_window: Literal[False] = False,
app: AppRef = None,
persist: bool = False,
**param_options: dict,
) -> Callable[[Callable[..., _R]], FunctionGui[_R]]: ...
@overload # noqa: E302
Expand All @@ -61,6 +63,7 @@ def magicgui( # noqa
result_widget: bool = False,
main_window: Literal[True],
app: AppRef = None,
persist: bool = False,
**param_options: dict,
) -> MainFunctionGui[_R]: ...
@overload # noqa: E302
Expand All @@ -75,6 +78,7 @@ def magicgui( # noqa
result_widget: bool = False,
main_window: Literal[True],
app: AppRef = None,
persist: bool = False,
**param_options: dict,
) -> Callable[[Callable[..., _R]], MainFunctionGui[_R]]: ...
@overload # noqa: E302
Expand Down
89 changes: 89 additions & 0 deletions magicgui/_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import os
import sys
from functools import wraps
from pathlib import Path
from time import time
from typing import Optional


def rate_limited(t):
"""Prevent a function from being called more than once in `t` seconds."""

def decorator(f):
last = [0.0]

@wraps(f)
def wrapper(*args, **kwargs):
if last[0] and (time() - last[0] < t):
return
last[0] = time()
return f(*args, **kwargs)

return wrapper

return decorator


# modified from appdirs: https://github.com/ActiveState/appdirs
# License: MIT
def user_cache_dir(
appname: Optional[str] = "magicgui", version: Optional[str] = None
) -> Path:
r"""Return full path to the user-specific cache dir for this application.
Typical user cache directories are:
Mac OS X: ~/Library/Caches/<AppName>
Unix: ~/.cache/<AppName> (XDG default)
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppName>\Cache # noqa
Vista: C:\Users\<username>\AppData\Local\<AppName>\Cache
Parameters
----------
appname : str, optional
Name of application. If None, just the system directory is returned.
by default "magicgui"
version : str, optional
an optional version path element to append to the path. You might want to use
this if you want multiple versions of your app to be able to run independently.
If used, this would typically be "<major>.<minor>". Only applied when appname is
present.
Returns
-------
str
Full path to the user-specific cache dir for this application.
"""
if sys.platform.startswith("java"):
import platform

os_name = platform.java_ver()[3][0]
if os_name.startswith("Windows"): # "Windows XP", "Windows 7", etc.
system = "win32"
elif os_name.startswith("Mac"): # "Mac OS X", etc.
system = "darwin"
else: # "Linux", "SunOS", "FreeBSD", etc.
# Setting this to "linux2" is not ideal, but only Windows or Mac
# are actually checked for and the rest of the module expects
# *sys.platform* style strings.
system = "linux2"
else:
system = sys.platform

home = Path.home()
if system == "win32":
_epath = os.getenv("LOCALAPPDATA")
path = Path(_epath).resolve() if _epath else home / "AppData" / "Local"
if appname:
path = path / appname / "Cache"
elif system == "darwin":
path = home / "Library" / "Caches"
if appname:
path = path / appname
else:
_epath = os.getenv("XDG_CACHE_HOME")
path = Path(_epath) if _epath else home / ".cache"
if appname:
path = path / appname
if appname and version:
path = path / version
return path
28 changes: 28 additions & 0 deletions magicgui/widgets/_bases/container_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,34 @@ def labels(self, value: bool):
widget = self.pop(index)
self.insert(index, widget)

NO_VALUE = "NO_VALUE"

def dict(self) -> dict:
"""Return dict of {name: value} for each widget in the container."""
return {w.name: getattr(w, "value", self.NO_VALUE) for w in self}

def _dump(self, path):
"""Dump the state of the widget to `path`."""
import pickle
from pathlib import Path

path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(pickle.dumps(self.dict()))

def _load(self, path, quiet=False):
"""Restore the state of the widget from previously saved file at `path`."""
import pickle
from pathlib import Path

path = Path(path)
if not path.exists() and quiet:
return
for key, val in pickle.loads(path.read_bytes()).items():
if val == self.NO_VALUE:
continue
getattr(self, key).value = val


class MainWindowWidget(ContainerWidget):
"""Top level Application widget that can contain other widgets."""
Expand Down
39 changes: 33 additions & 6 deletions magicgui/widgets/_function_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from types import FunctionType
from typing import (
TYPE_CHECKING,
Expand All @@ -20,6 +21,7 @@
cast,
)

from magicgui._util import rate_limited
from magicgui.application import AppRef
from magicgui.events import EventEmitter
from magicgui.signature import MagicSignature, magic_signature
Expand Down Expand Up @@ -95,6 +97,10 @@ class FunctionGui(Container, Generic[_R]):
Will be passed to `magic_signature` by default ``None``
name : str, optional
A name to assign to the Container widget, by default `function.__name__`
persist : bool, optional
If `True`, when parameter values change in the widget, they will be stored to
disk (in `~/.config/magicgui/cache`) and restored when the widget is loaded
again with ``persist = True``. By default, `False`.
Raises
------
Expand All @@ -117,6 +123,7 @@ def __init__(
result_widget: bool = False,
param_options: dict[str, dict] | None = None,
name: str = None,
persist: bool = False,
**kwargs,
):
if not callable(function):
Expand All @@ -136,6 +143,7 @@ def __init__(
if tooltips:
_inject_tooltips_from_docstrings(function.__doc__, param_options)

self.persist = persist
self._function = function
self.__wrapped__ = function
# it's conceivable that function is not actually an instance of FunctionType
Expand Down Expand Up @@ -193,9 +201,17 @@ def _disable_button_and_call(val):
self._result_widget.enabled = False
self.append(self._result_widget)

if persist:
self._load(quiet=True)

self._auto_call = auto_call
if auto_call:
self.changed.connect(lambda e: self.__call__())
self.changed.connect(self._on_change)

def _on_change(self, e):
if self.persist:
self._dump()
if self._auto_call:
self()

@property
def call_count(self) -> int:
Expand All @@ -206,10 +222,6 @@ def reset_call_count(self) -> None:
"""Reset the call count to 0."""
self._call_count = 0

# def __delitem__(self, key: int | slice):
# """Delete a widget by integer or slice index."""
# raise AttributeError("can't delete items from a FunctionGui")

@property
def return_annotation(self):
"""Return annotation to use when converting to :class:`inspect.Signature`.
Expand Down Expand Up @@ -357,6 +369,21 @@ def __set__(self, obj, value):
"""Prevent setting a magicgui attribute."""
raise AttributeError("Can't set magicgui attribute")

@property
def _dump_path(self) -> Path:
from .._util import user_cache_dir

name = getattr(self._function, "__qualname__", self._callable_name)
name = name.replace("<", "-").replace(">", "-") # e.g. <locals>
return user_cache_dir() / f"{self._function.__module__}.{name}"

@rate_limited(0.25)
def _dump(self, path=None):
super()._dump(path or self._dump_path)

def _load(self, path=None, quiet=False):
super()._load(path or self._dump_path, quiet=quiet)


class MainFunctionGui(FunctionGui[_R], MainWindow):
"""Container of widgets as a Main Application Window."""
Expand Down
41 changes: 41 additions & 0 deletions tests/test_persistence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import time
from unittest.mock import patch

from magicgui._util import user_cache_dir
from magicgui.widgets import FunctionGui


def test_user_cache_dir():
ucd = user_cache_dir()
import sys
from pathlib import Path

home = Path.home().resolve()
if sys.platform == "win32":
assert str(ucd) == str(home / "AppData" / "Local" / "magicgui" / "Cache")
elif sys.platform == "darwin":
assert str(ucd) == str(home / "Library" / "Caches" / "magicgui")
else:
assert str(ucd) == str(home / ".cache" / "magicgui")


def test_persistence(tmp_path):
"""Test that we can persist values across instances."""

def _my_func(x: int, y="hello"):
...

with patch("magicgui._util.user_cache_dir", lambda: tmp_path):
fg = FunctionGui(_my_func, persist=True)
assert str(tmp_path) in str(fg._dump_path)
assert fg.x.value == 0
fg.x.value = 10
time.sleep(0.26) # required by rate limit
fg.y.value = "world"

# second instance should match values of first
fg2 = FunctionGui(_my_func, persist=True)
assert fg2.x.value == 10
assert fg2.y.value == "world"
assert fg2.__signature__ == fg.__signature__
assert fg2 is not fg

0 comments on commit 7b445ed

Please sign in to comment.