[Dextrose] [PATCH] Resolution for ticket au#887.
Anish Mangal
anish at activitycentral.org
Tue Sep 20 08:41:45 EDT 2011
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1
Thanks for the patch!
On 09/20/2011 05:22 PM, Ajay Garg wrote:
> === Usability notes ===
>
> 1. Now, besides an interactive 'Software Update', a background
> thread also checks for available activity updates. To prevent
> possible collisions in a rare situation when the interactive
> session and the background thread might run at the same time,
> synchronization has been used.
>
Would a simple lock file mechanism work?
> 2. When a user enters into 'Software Update' section-view,
> exclusive control is gained. The exclusive control is
> retained unless and until this section-view is exited
> (via 'Cancel' or 'Ok' toolbar buttons).
>
> If the background thread wishes to run, it waits,
> while the user interacts with the section-view.
>
> 3. If the background thread is running, it has exclusive control.
> If the user wishes to enter into an interactive session (by
> clicking onto the 'Software Update' section-view-icon),
> following happens :
>
> a. The section-view shows 'Getting ready...' markup,
> with the message 'Waiting for activity-update-
> notifier thread to relinquish control...'
> b. The (interactive) thread waits, until the background
> thread relinquishes control.
> c. After the (exclusive) control is retained, the
> interactive thread proceeds normally, checking
> for updates, followed by user-wish of installing
> updates.
>
> === Implementation notes ===
>
> For this, comments have been added into the code appropriately.
>
> === Configuration notes ===
>
> 1. Following gconf keys can be used to configure this feature.
>
> a. KEY : /desktop/sugar/activity_notifier_enabled
> PURPOSE : Enable/Disable the notifier background thread
> TYPE : boolean
> DEFAULT : true (enables the notifier background thread)
>
> b. KEY : /desktop/sugar/activity_notifier_interval_first_time
> PURPOSE : Interval after which the background thread runs for
> the first time
> TYPE : int
> DEFAULT : 120 (seconds)
>
> c. KEY : /desktop/sugar/activity_notifier_interval_second_time_onwards
> PURPOSE : Interval for the subsequen runs
> TYPE : int
> DEFAULT : 604800 (seconds). This is equivalent to 7 days.
>
> 2. Note that these keys would be used on a pre-configured basis.
> No change-notifier callbacks have been added into the code,
> to detect key-changes during runtime.
>
> === Design enhancements/changes ===
>
> 1. A mechanism for loading "daemon" classes has been added into
> "src/jarabe/controlpanel/homewindow.py" file.
> It searches for an attribute "DAEMON" in the "__init__.py" files
> of "extensions/cpsection" modules. If the attribute is found, the
> corresponding class is loaded.
>
> Currently, only "Software Update" module has the attribute "DAEMON"
> in "__init__.py". The corresponding class - "ActivityNotifier" is loaded.
> Being a subclass of "threading.Thread", this class runs as a daemon.
>
> Note that the loading of these "daemon" classes occurs during the loading
> of home-window, since that is the first thing that happens after boot.
>
> Signed-off-by: Ajay Garg <ajay at sugarlabs.org>
> ---
> data/sugar.schemas.in | 36 +++++++++
> extensions/cpsection/updater/Makefile.am | 3 +-
> extensions/cpsection/updater/__init__.py | 1 +
> extensions/cpsection/updater/daemon.py | 93 +++++++++++++++++++++++
> extensions/cpsection/updater/model.py | 24 ++++++
> extensions/cpsection/updater/view.py | 118 +++++++++++++++++++++++------
> src/jarabe/controlpanel/gui.py | 20 +++++
> src/jarabe/controlpanel/sectionview.py | 32 ++++++++
> src/jarabe/desktop/homewindow.py | 35 +++++++++
> 9 files changed, 336 insertions(+), 26 deletions(-)
> create mode 100644 extensions/cpsection/updater/daemon.py
>
> diff --git a/data/sugar.schemas.in b/data/sugar.schemas.in
> index aa031da..54ba4e2 100644
> --- a/data/sugar.schemas.in
> +++ b/data/sugar.schemas.in
> @@ -129,6 +129,42 @@
> </schema>
>
> <schema>
> + <key>/schemas/desktop/sugar/activity_notifier_enabled</key>
> + <applyto>/desktop/sugar/activity_notifier_enabled</applyto>
> + <owner>sugar</owner>
> + <type>bool</type>
> + <default>true</default>
> + <locale name="C">
> + <short>Flag to turn on/off the activity-update-notifier.</short>
> + <long>This key contains the flag, which can be used to turn on/off the activity-update-notifier.</long>
> + </locale>
> + </schema>
> +
> + <schema>
> + <key>/schemas/desktop/sugar/activity_notifier_interval_first_time</key>
> + <applyto>/desktop/sugar/activity_notifier_interval_first_time</applyto>
> + <owner>sugar</owner>
> + <type>int</type>
> + <default>120</default>
> + <locale name="C">
> + <short>Interval for activity-update-notifier timer (in seconds), for first run</short>
> + <long>This key contains the interval, after which, the activity-update-notifier runs for the first time. Default value is 120 seconds.</long>
> + </locale>
> + </schema>
> +
> + <schema>
> + <key>/schemas/desktop/sugar/activity_notifier_interval_second_time_onwards</key>
> + <applyto>/desktop/sugar/activity_notifier_interval_second_time_onwards</applyto>
> + <owner>sugar</owner>
> + <type>int</type>
> + <default>604800</default>
> + <locale name="C">
> + <short>Interval for activity-update-notifier timer (in seconds), for second run onwards</short>
> + <long>This key contains the interval, for activity-update-notifier, for second and subsequent runs. Default value is 604800 seconds (~ 7 days)</long>
> + </locale>
> + </schema>
> +
Looking at the problem overall, we just need one gconf key to enable,
disable the notifications. The other two keys are not really needed.
Perhaps we could just follow the approach dextrose-updater uses. It
checks every hour if it has run for the day, and if it has, does nothing.
cron jobs are ideal for periodic tasks.
> + <schema>
> <key>/schemas/desktop/sugar/backup_url</key>
> <applyto>/desktop/sugar/backup_url</applyto>
> <owner>sugar</owner>
> diff --git a/extensions/cpsection/updater/Makefile.am b/extensions/cpsection/updater/Makefile.am
> index 897dbf3..d346cac 100644
> --- a/extensions/cpsection/updater/Makefile.am
> +++ b/extensions/cpsection/updater/Makefile.am
> @@ -5,4 +5,5 @@ sugardir = $(pkgdatadir)/extensions/cpsection/updater
> sugar_PYTHON = \
> __init__.py \
> model.py \
> - view.py
> + view.py \
> + daemon.py
> diff --git a/extensions/cpsection/updater/__init__.py b/extensions/cpsection/updater/__init__.py
> index 6010615..7d4b432 100644
> --- a/extensions/cpsection/updater/__init__.py
> +++ b/extensions/cpsection/updater/__init__.py
> @@ -17,6 +17,7 @@
> from gettext import gettext as _
>
> CLASS = 'ActivityUpdater'
> +DAEMON = 'ActivityNotifier'
> ICON = 'module-updater'
> TITLE = _('Software update')
> KEYWORDS = ['software', 'activity', 'update']
> diff --git a/extensions/cpsection/updater/daemon.py b/extensions/cpsection/updater/daemon.py
> new file mode 100644
> index 0000000..a1b54e1
> --- /dev/null
> +++ b/extensions/cpsection/updater/daemon.py
> @@ -0,0 +1,93 @@
> +# Copyright (C) 2011, Ajay Garg
> +#
Include your email address (preferable)
> +# This program is free software: you can redistribute it and/or modify
> +# it under the terms of the GNU General Public License as published by
> +# the Free Software Foundation, either version 2 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
> +# GNU General Public License for more details.
> +#
> +# You should have received a copy of the GNU General Public License
> +# along with this program. If not, see <http://www.gnu.org/licenses/>.
> +
> +import logging
> +import threading
> +import time
> +
> +from gettext import gettext as _
> +
> +import gconf
> +import gobject
> +
> +from jarabe import frame
> +from model import UpdateModel
> +from model import ModelAttributesHolder
> +
> +gobject.threads_init()
> +
> +client = gconf.client_get_default()
> +NOTIFIER_ENABLED = \
> + client.get_bool('/desktop/sugar/activity_notifier_enabled')
> +NOTIFIER_INTERVAL_FIRST_TIME = \
> + client.get_int('/desktop/sugar/activity_notifier_interval_first_time')
> +NOTIFIER_INTERVAL_SECOND_TIME_ONWARDS = \
> + client.get_int('/desktop/sugar/activity_notifier_interval_second_time_onwards')
> +
> +
> +class ActivityNotifier(threading.Thread):
> +
> +
> + def __init__(self):
> + threading.Thread.__init__(self)
> + self.start()
> +
> +
> + def run(self):
> + if NOTIFIER_ENABLED:
> + self._model = UpdateModel()
> + self._model.connect('progress', self._progress_cb)
> +
> + time.sleep(NOTIFIER_INTERVAL_FIRST_TIME)
> +
> + while 1:
> + self._timer_cb()
> + time.sleep(NOTIFIER_INTERVAL_SECOND_TIME_ONWARDS)
> +
Though I'm opposed to this approach in general, here we should use
gobject.timeout_add()
time.sleep() won't work well with gtk threading IMO
> +
> + def _timer_cb(self):
> + self.timer_cb_execute()
> + return True
> +
the '_cb' is meant to signify that its a callback function, which it
isn't. Also, minor point, _cb (callback) functions in general should
start with two underscores, so __timer_cb()
> +
> + def timer_cb_execute(self):
> + ModelAttributesHolder.grab_exclusive_control(True)
> +
> + self._model.check_updates()
> + return True
> +
> +
> + def _progress_cb(self, model, action, bundle_name, current, total):
> + if current == total and action == UpdateModel.ACTION_CHECKING:
> + self._finished_checking()
> +
> +
> + def _finished_checking(self):
> + available_updates = len(self._model.updates)
> +
> + ModelAttributesHolder.yield_exclusive_control()
> +
> + if available_updates:
> + title = _('Activity Updates available')
> + msg = _('%d updates available.\n'
> + 'Please run \'Software Update\' '
> + 'to install these updates.' % available_updates)
> + gobject.idle_add(lambda:
> + frame.get_view().add_message(summary=title,
> + body=msg))
> + else:
> + # Do nothing. No updates available.
> + logging.debug('No activity-updates found. Not showing any'
> + 'notification')
> diff --git a/extensions/cpsection/updater/model.py b/extensions/cpsection/updater/model.py
> index 39d0b1f..5e6fc73 100755
> --- a/extensions/cpsection/updater/model.py
> +++ b/extensions/cpsection/updater/model.py
> @@ -38,6 +38,30 @@ from sugar.bundle.bundleversion import NormalizedVersion
> from jarabe.model import bundleregistry
>
> from backends import microformat
> +from threading import Condition
> +
> +
> +class ModelAttributesHolder():
> +
> + background_action = {}
> + flag = True
> + condition = Condition()
> +
> + @staticmethod
> + def grab_exclusive_control(is_bg_action):
> + ModelAttributesHolder.condition.acquire()
> + while not ModelAttributesHolder.flag:
> + ModelAttributesHolder.condition.wait()
> + ModelAttributesHolder.flag = False
> + ModelAttributesHolder.background_action = is_bg_action
> + ModelAttributesHolder.condition.release()
> +
> + @staticmethod
> + def yield_exclusive_control():
> + ModelAttributesHolder.condition.acquire()
> + ModelAttributesHolder.flag = True
> + ModelAttributesHolder.condition.notify()
> + ModelAttributesHolder.condition.release()
>
> class MetaBundle():
>
> diff --git a/extensions/cpsection/updater/view.py b/extensions/cpsection/updater/view.py
> index d257b56..a67b894 100644
> --- a/extensions/cpsection/updater/view.py
> +++ b/extensions/cpsection/updater/view.py
> @@ -29,12 +29,62 @@ from sugar.graphics.icon import Icon, CellRendererIcon
> from jarabe.controlpanel.sectionview import SectionView
>
> from model import UpdateModel
> +from model import ModelAttributesHolder
>
> _DEBUG_VIEW_ALL = True
>
>
> class ActivityUpdater(SectionView):
>
> + """
> + Notes:
> +
> + 1. This class, represents the 'Software Update' section-view.
> + A new instance of ActivityUpdater is created, everytime user
> + enters into the 'Software Update' section-view.
> +
> + 2. Now, besides an interactive 'Software Update', a background
> + thread also checks for available activity updates. (See
> + 'daemon.py'). To prevent possible collisions in a rare
> + situation when the interactive session and the background
> + thread might run at the same time, synchronization has
> + been used.
> + (
> + If both are allowed to run simultaneously, I was getting
> + errors in gio.File.read_async()
> + )
> +
> + 3. When a user enters into 'Software Update' section-view,
> + exclusive control is gained. The exclusive control is
> + retained unless and until this section-view is exited
> + (via 'Cancel' or 'Ok' toolbar buttons).
> +
> + See overridden 'self.post_show_section_view()' and
> + 'self.post_exit()' methods.
> +
> + If the background thread wishes to run, it waits,
> + while the user interacts with the section-view.
> +
> +
> + 4. If the background thread is running, it has exclusive control.
> + If the user wishes to enter into an interactive session (by
> + clicking onto the 'Software Update' section-view-icon),
> + following happens :
> +
> + a. The section-view shows 'Getting ready...' markup,
> + with the message 'Waiting for activity-update-
> + notifier thread to relinquish control...'
> + b. The (interactive) thread waits, until the background
> + thread relinquishes control.
> + c. After the (exclusive) control is retained, the
> + interactive thread proceeds normally, checking
> + for updates, followed by user-wish of installing
> + updates.
> +
> + See overridden 'self.post_show_section_view()' method.
> + """
> +
> +
> def __init__(self, model, alerts):
> SectionView.__init__(self)
>
> @@ -68,7 +118,13 @@ class ActivityUpdater(SectionView):
> self._update_box = None
> self._progress_pane = None
>
> - self._refresh()
> + # Set up an inital set-up for section-view.
> + top_message = _('Getting ready...')
> + self._top_label.set_markup('<big>%s</big>' % top_message)
> + self.__progress_cb('progress', UpdateModel.ACTION_CHECKING,
> + 'Waiting for '
> + 'activity-update-notifier thread to '
> + 'relinquish control', 0, 3, False)
>
> def _switch_to_update_box(self):
> if self._update_box in self.get_children():
> @@ -113,7 +169,8 @@ class ActivityUpdater(SectionView):
> self.remove(self._update_box)
> self._update_box = None
>
> - def __progress_cb(self, model, action, bundle_name, current, total):
> + def __progress_cb(self, model, action, bundle_name, current, total,
> + check_for_background_action=True):
/me doesn't like the approach of checking this so deep in the code.
> if current == total and action == UpdateModel.ACTION_CHECKING:
> self._finished_checking()
> return
> @@ -121,36 +178,41 @@ class ActivityUpdater(SectionView):
> self._finished_updating(int(current))
> return
>
> - if action == UpdateModel.ACTION_CHECKING:
> - message = _('%s...') % bundle_name
> - elif action == UpdateModel.ACTION_DOWNLOADING:
> - message = _('Downloading %s...') % bundle_name
> - elif action == UpdateModel.ACTION_UPDATING:
> - message = _('Updating %s...') % bundle_name
> + if (not check_for_background_action) or \
> + ((check_for_background_action) and \
Same as above
> + (not ModelAttributesHolder.background_action)):
> + if action == UpdateModel.ACTION_CHECKING:
> + message = _('%s...') % bundle_name
> + elif action == UpdateModel.ACTION_DOWNLOADING:
> + message = _('Downloading %s...') % bundle_name
> + elif action == UpdateModel.ACTION_UPDATING:
> + message = _('Updating %s...') % bundle_name
>
> - self._switch_to_progress_pane()
> - self._progress_pane.set_message(message)
> - self._progress_pane.set_progress(current / float(total))
> + self._switch_to_progress_pane()
> + self._progress_pane.set_message(message)
> + self._progress_pane.set_progress(current / float(total))
>
> def _finished_checking(self):
> logging.debug('ActivityUpdater._finished_checking')
> available_updates = len(self._model.updates)
> - if not available_updates:
> - top_message = _('Your software is up-to-date')
> - else:
> - top_message = ngettext('You can install %s update',
> - 'You can install %s updates',
> - available_updates)
> - top_message = top_message % available_updates
> - top_message = gobject.markup_escape_text(top_message)
>
> - self._top_label.set_markup('<big>%s</big>' % top_message)
> + if not ModelAttributesHolder.background_action:
Same as above
> + if not available_updates:
> + top_message = _('Your software is up-to-date')
> + else:
> + top_message = ngettext('You can install %s update',
> + 'You can install %s updates',
> + available_updates)
> + top_message = top_message % available_updates
> + top_message = gobject.markup_escape_text(top_message)
> +
> + self._top_label.set_markup('<big>%s</big>' % top_message)
>
> - if not available_updates:
> - self._clear_center()
> - else:
> - self._switch_to_update_box()
> - self._update_box.refresh()
> + if not available_updates:
> + self._clear_center()
> + else:
> + self._switch_to_update_box()
> + self._update_box.refresh()
>
> def __refresh_button_clicked_cb(self, button):
> self._refresh()
> @@ -180,6 +242,12 @@ class ActivityUpdater(SectionView):
> def undo(self):
> self._model.cancel()
>
> + def post_show_section_view(self):
> + ModelAttributesHolder.grab_exclusive_control(False)
> + self._refresh()
> +
> + def post_exit(self):
> + ModelAttributesHolder.yield_exclusive_control()
>
> class ProgressPane(gtk.VBox):
> """Container which replaces the `ActivityPane` during refresh or
- -1 to modifying big parts of code where we could check at just one point
and do something/nothing accordingly.
> diff --git a/src/jarabe/controlpanel/gui.py b/src/jarabe/controlpanel/gui.py
> index b4879c6..f420a96 100644
> --- a/src/jarabe/controlpanel/gui.py
> +++ b/src/jarabe/controlpanel/gui.py
> @@ -177,6 +177,18 @@ class ControlPanel(gtk.Window):
> self._options[option]['button'] = sectionicon
>
> def _show_main_view(self):
> +
> + # 1. This method has two callers.
> + # 2. One, when the main_view is shown upon clicking on
> + # 'My Settings' from the home-window buddy-menu.
> + # 3. Second, when the main_view is shown, after exiting
> + # from a section_view.
> + # 4. In the second case, when we are coming out of the
> + # section-view, call the 'post_exit' method for the section-view
> + if self._section_view:
> + self._section_view.post_exit()
> +
> +
> self._set_toolbar(self._main_toolbar)
> self._main_toolbar.show()
> self._set_canvas(self._scrolledwindow)
> @@ -244,6 +256,14 @@ class ControlPanel(gtk.Window):
> self._main_view.modify_bg(gtk.STATE_NORMAL,
> style.COLOR_BG_CP.get_gdk_color())
>
> +
> + # 1. Adding 'post_show_section_view()' method.
> + # 2. Please see 'jarabe.controlpanel.sectionview.SectionView'
> + # for definition.
> + # 3. This needs to done in 'idle' mode, to prevent
> + # UI freeze.
> + gobject.idle_add(self._section_view.post_show_section_view)
> +
> def set_section_view_auto_close(self):
> """Automatically close the control panel if there is "nothing to do"
> """
> diff --git a/src/jarabe/controlpanel/sectionview.py b/src/jarabe/controlpanel/sectionview.py
> index 4b5751f..acd06fc 100644
> --- a/src/jarabe/controlpanel/sectionview.py
> +++ b/src/jarabe/controlpanel/sectionview.py
> @@ -52,3 +52,35 @@ class SectionView(gtk.VBox):
> def undo(self):
> """Undo here the changes that have been made in this section."""
> pass
> +
> + def post_show_section_view(self):
> + """
> + This method performs actions, after ::
> +
> + 1. the initial set-up for the section-view has been set up
> + (in '__init__()' of subclasses of
> + 'jarabe.controlpanel.sectionview.SectionView')
> + 2. the section-view 'shown' (in 'show_section_view()' of
> + 'jarabe.controlpanel.gui.ControlPanel')
> +
> + By default, nothing generic needs to be done, as generally, the
> + initial set-up is good enough (when no user-interaction is
> + required).
> +
> + For a class that actually does something specific, please see
> + this over-ridden method in
> + 'extensions.cpcsection.updater.view.ActivityUpdater'
> + """
> + pass
> +
> + def post_exit(self):
> + """
> + This method does the cleaning-up stuff, just as we are about
> + to leave the section-view.
> +
> + By default, nothing generic needs to be done.
> +
> + Any subclasses (eg. - 'extensions.cpcsection.updater.view.ActivityUpdater'),
> + may override this method.
> + """
> + pass
> diff --git a/src/jarabe/desktop/homewindow.py b/src/jarabe/desktop/homewindow.py
> index f101757..0bd9d05 100644
> --- a/src/jarabe/desktop/homewindow.py
> +++ b/src/jarabe/desktop/homewindow.py
> @@ -16,6 +16,7 @@
>
> import logging
>
> +import os
> import gobject
> import gtk
> import dbus
> @@ -24,6 +25,7 @@ from gettext import gettext as _
> from sugar.graphics import style
> from sugar.graphics import palettegroup
>
> +from jarabe import config
> from jarabe.desktop.meshbox import MeshBox
> from jarabe.desktop.homebox import HomeBox
> from jarabe.desktop.groupbox import GroupBox
> @@ -31,6 +33,7 @@ from jarabe.desktop.transitionbox import TransitionBox
> from jarabe.model.shell import ShellModel
> from jarabe.model import shell
> from jarabe.model import notifications
> +from jarabe.controlpanel.gui import ModelWrapper
>
>
> _HOME_PAGE = 0
> @@ -98,6 +101,8 @@ class HomeWindow(gtk.Window):
> systembus.add_signal_receiver(self.__relogin_cb, 'Relogin',
> _DBUS_SYSTEM_IFACE)
>
> + gobject.idle_add(self.load_cpsection_daemon_classes)
> +
> def _system_alert(self, replaces_id, app_icon, message):
> service = notifications.get_service()
> service.notification_received.send(self,app_name='system',
> @@ -237,6 +242,36 @@ class HomeWindow(gtk.Window):
> self.get_window().set_cursor(gtk.gdk.Cursor(gtk.gdk.WATCH))
> gobject.idle_add(action_wrapper, old_cursor)
>
> + def load_cpsection_daemon_classes(self):
> + """Get the available option information from the extensions
> + """
> + options = {}
> +
> + path = os.path.join(config.ext_path, 'cpsection')
> + folder = os.listdir(path)
> +
> + for item in folder:
> + if os.path.isdir(os.path.join(path, item)) and \
> + os.path.exists(os.path.join(path, item, '__init__.py')):
> + try:
> + mod = __import__('.'.join(('cpsection', item)),
> + globals(), locals(), [item])
> + daemon_class = getattr(mod, 'DAEMON', None)
> + if daemon_class is not None:
> + options[item] = {}
> + options[item]['daemon'] = daemon_class
> + except Exception:
We should not try to catch all exceptions here, but *only* the ones
we're expecting to occur.
> + logging.exception('Exception while loading extension:')
> +
> +
> + option_keys = options.keys()
> + for key in option_keys:
> + mod = __import__('.'.join(('cpsection', key, 'daemon')),
> + globals(), locals(), ['daemon'])
> + if mod:
> + daemon_class = getattr(mod, options[key]['daemon'], None)
> + daemon_class()
> +
>
> def get_instance():
> global _instance
General comments:
* I've not reviewed the patch very deeply, but I feel we could implement
this in a cleaner way (with fewer lines of code).
* -1 to another python thread running in the background for this.
* Perhaps we could discuss the ideal solution (in the related [DESIGN]
thread). Sascha, Aleksey?
* Though the comments in-lined in the code were helpful, we could remove
some of the more verbose ones when committing this to the repo.
Reviewed-by: Anish Mangal <anish at activitycentral.com>
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.11 (GNU/Linux)
Comment: Using GnuPG with Fedora - http://enigmail.mozdev.org/
iQEcBAEBAgAGBQJOeIn2AAoJEBoxUdDHDZVp3BQH/RySJQ3hkkjeN9kH6/ee2kxH
oy4kDivFjgACk5klPRnBcO+zwvTj49qGlDhCF426LOt8D6uZII5rM0SHzbtb8vEc
k9pcOK6qhJw6si7prHwh9apt8JPGCif9nxiiWdy5Lse93WiE/pzybI632/8hjLg2
UzlH5yrK063D0FxQyeOfyxHfcNpeI6wbUTC6IqZI7086YZx5st0IiFI76cXEGZRB
QM0FWmCbbun5ebhHh3G5epOkPilBqLibtr1wSg3ZiJVwaCXTVVADPUYQ8RVzGtsK
9b7v/6aNa6U5fVLfYCSvfgAQaHawCrnUdTDUvw2M2KCSnOeLjL1DQ+E1O6uwxuA=
=E3Q9
-----END PGP SIGNATURE-----
More information about the Dextrose
mailing list