Merge lp:~dylanmccall/update-manager/group-by-applications into lp:update-manager

Proposed by Dylan McCall on 2012-06-29
Status: Merged
Merged at revision: 2582
Proposed branch: lp:~dylanmccall/update-manager/group-by-applications
Merge into: lp:update-manager
Diff against target: 859 lines (+405/-239)
2 files modified
UpdateManager/Core/UpdateList.py (+175/-100)
UpdateManager/UpdatesAvailable.py (+230/-139)
To merge this branch: bzr merge lp:~dylanmccall/update-manager/group-by-applications
Reviewer Review Type Date Requested Status
Dylan McCall (community) Resubmit on 2012-11-10
Barry Warsaw (community) 2012-06-29 Needs Fixing on 2012-07-17
Matthew Paul Thomas 2012-07-17 Pending
Review via email: mp+112678@code.launchpad.net

Description of the change

This is a first step towards grouping updates according to the specification at https://wiki.ubuntu.com/SoftwareUpdates.

There are some details still to be worked out, but the feature so far is functional :)

Changes:
 - Changed the list of updates to a treeview, where updates are collapsed under application names.
 - UpdateList links updated packages to applications and groups them according to dependencies.
 - Restart required indicator is shown for packages with "XB-Restart-Required: system." There is a temporary override for linux-meta for testing purposes.
 - Update origins are not displayed.

Known issues, to be resolved with future work:
 - Indentation in the update tree view is presently hard coded and probably belongs (at least partly) in the light-themes package.
 - The 'Ubuntu base' update group should list specific core packages, not 'everything else.' The specification is unclear on what to do with everything else, so it's like this for the time being.
 - The Updates Required icon does not have its corresponding UI element below the download size text.
 - The Download column does not yet show a check mark for downloaded packages.
 - "Terminal" has an expander, with "gnome-terminal" as a child. Instead, group headers should simply be packages instead of their own things.

So, it isn't _finished_, but it does seem to be in a working state. The reason I'm interested in merging with trunk is because I want to keep our code in sync. Most of the crazy merge conflicts are probably done with now (especially since we seem to be standardized on 4 space indents). However, my diff spans _a lot_ of UpdateList.py and UpdatesAvailable.py, so any conflicts can be a real pain to deal with.

To post a comment you must log in.
2442. By Dylan McCall on 2012-06-29

Attempt to load a symbolic icon for 'restart required'.
Don't select second item in update list.

Barry Warsaw (barry) wrote :

See detailed review sent separately.

review: Needs Fixing
Barry Warsaw (barry) wrote :
Download full text (43.2 KiB)

Hi Dylan,

Thanks for your contribution to Update Manager, and to Ubuntu!

I've provided some detailed comments on the code below.

One thing I'd really like to see is some unittests, especially for the new
code in UpdateList.py. Would you like to take a crack at adding a few for
some of the new methods and classes you're adding? Have you run the existing
unittests to see if your changes have broken any of them? While Update
Manager doesn't have a fantastic test suite, I'm not crazy about making things
worse by adding more untested code.

Someone should also review the UI changes before this branch lands, probably
mpt or someone else on the design team. I'll request a review from mpt to
start with.

I did notice one UI problem when I ran your branch on Quantal. I see an
update for Ubuntu base (which I gather is a catch-all for anything that
doesn't fit into any other category). However, when I expand the arrow for
that group, I'm still left with a very short window, one row that contains the
"Ubuntu base" string, and a very long and mostly unusable scroll bar. Maybe
the window should expand vertically to fit the sub-package contents better, or
maybe the details window should be taller to start with.

Other than that, I think the groupings are a nice refactoring of the
information presented by Update Manager!

I'll mark the branch Needs Fixing for now, but I will be happy to re-review it
if you make the suggested changes.

=== modified file 'UpdateManager/Core/UpdateList.py'
--- UpdateManager/Core/UpdateList.py 2012-06-28 00:10:23 +0000
+++ UpdateManager/Core/UpdateList.py 2012-07-17 18:03:22 +0000
> @@ -1,25 +1,29 @@
> -# UpdateList.py
> +# UpdateList.py
> # -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
> -#
> +#

You need to be really careful about adding extra whitespace, as your patch
does here. There are several reason for this, but probably the most important
one is that such whitespace-only changes are just noise that distract from the
substantive changes, and reviews thereof. There are many options for ensuring
"whitespace normalization" in Python code, some of which can be accomplished
by your editor. E.g. I have nice functions for Emacs:

http://mail.python.org/pipermail/python-dev/2007-April/072773.html

> # Copyright (c) 2004-2008 Canonical
> -#
> +#
> # Author: Michael Vogt <email address hidden>
> -#
> -# This program is free software; you can redistribute it and/or
> -# modify it under the terms of the GNU General Public License as
> +#
> +# 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, wri...

2443. By Dylan McCall on 2012-08-18

Merged changes from trunk.
Removed unnecessary TODO notes in UpdateList.py

2444. By Dylan McCall on 2012-08-20

Fixed unintended formatting changes.

2445. By Dylan McCall on 2012-08-20

Merged changes from trunk.

2446. By Dylan McCall on 2012-08-20

Fixed some code style errors in UpdateList.

Dylan McCall (dylanmccall) wrote :

Okay, I'm finally working on this and I'll let you know when it's all done. (Couple days, I hope). A lot of the weirder issues are due to me manually merging changes, apparently to the wrong version of UpdateList.py. Unfortunate waste of time, so thank you for being so patient, Barry! :)

2447. By Dylan McCall on 2012-08-21

Fixed lines longer than 79 characters in UpdateList.py.

2448. By Dylan McCall on 2012-11-10

Fixed style and commenting issues in UpdatesAvailable.py.

2449. By Dylan McCall on 2012-11-10

Merged changes from trunk

Dylan McCall (dylanmccall) wrote :

There, I think that covers the immediate problems. Those bizarre whitespace changes were because mterry had cleaned stuff up beautifully (along with the Python 3 update) between me starting and finishing this, and then I merged changes in the worst possible way. I'm sorry for taking so very long to get back to you.

Replacing pkg with package in UpdateList.py:
I'd like to leave "pkg" be in this branch, if that's okay, just because pkg seems to appear consistently across the application and in the previous version of UpdateList.py (even in the apt.pkg library we import). I agree it would be lovely to clean up, though. I honestly do cringe every time I abbreviate something :)

You were wondering about that weird function called _rate_application_for_package. Originally I had expected to need some fancier heuristics, but we seem to be getting along fine with what we have here. I agree with you, and I'll deal with that soon.

get_application_for_package:
GIO has a lovely function to parse a .desktop file, spitting out a class with a (localized) name, icon and such. The important bit is it's the same code we use in places like the Unity dash or the Applications menu in Gnome Panel, so it's all nice and familiar. Adding some comments to explain the purpose...

APP_INSTALL_PATH: Right now that is effectively the same thing that happens in Software Centre. I'm happy to turn it into a config parameter :)

I have some refactoring to do (and some new designs to implement!), so I think I will do that, and I will try to do tests and documentation alongside that work. I will resubmit then.

review: Resubmit
Marco Biscaro (marcobiscaro2112) wrote :

I tried to merge trunk and got conflicts. You should merge trunk again and fix them (in UpdateManager/Core/UpdateList.py).

Some details:

=== modified file 'UpdateManager/Core/UpdateList.py'
--- UpdateManager/Core/UpdateList.py 2012-08-20 15:32:45 +0000
+++ UpdateManager/Core/UpdateList.py 2012-11-10 06:29:19 +0000
@@ -1,7 +1,7 @@
 # UpdateList.py
 # -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
 #
-# Copyright (c) 2004-2012 Canonical
+# Copyright (c) 2004-2008 Canonical

I think this shouldn't be modified.

 #
 # Author: Michael Vogt <email address hidden>
 #

You modified this file a lot, I think your name should be here too. :)

@@ -22,53 +22,103 @@

 from __future__ import print_function

+import warnings
+warnings.filterwarnings("ignore", "Accessed deprecated property",
+ DeprecationWarning)
+
 from gettext import gettext as _
-
-import apt
-import logging
 import operator
-import random
+import itertools
 import subprocess
+import os
+import glob
 import sys

What about alphabetical order here?

Michael Terry (mterry) wrote :

Dylan, I wanted to help push this over the hump. I've got a merged-with-trunk branch here: lp:~mterry/update-manager/group-by-application

I'll look into adding some unittests next and fixing the single-package headers next. Is there a team we could put a shared branch under?

Dylan McCall (dylanmccall) wrote :

Cool! There is not one at the moment, but feel free to add me to one. (I'll
be able to do that in about 8 hours).

I have a few local changes that I'm aiming to finish up and push by Sunday,
so we might want to chat about those over email or IRC or something, too.
They could make things a lot easier (once they are not broken),
particularly with single packages and packages that aren't applications;)

On Mon, Dec 17, 2012 at 11:04 AM, Michael Terry <<email address hidden>
> wrote:

> Dylan, I wanted to help push this over the hump. I've got a
> merged-with-trunk branch here:
> lp:~mterry/update-manager/group-by-application
>
> I'll look into adding some unittests next and fixing the single-package
> headers next. Is there a team we could put a shared branch under?
> --
>
> https://code.launchpad.net/~dylanmccall/update-manager/group-by-applications/+merge/112678
> You are the owner of lp:~dylanmccall/update-manager/group-by-applications.
>

Marco Biscaro (marcobiscaro2112) wrote :

I can help too!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'UpdateManager/Core/UpdateList.py'
2--- UpdateManager/Core/UpdateList.py 2012-08-20 15:32:45 +0000
3+++ UpdateManager/Core/UpdateList.py 2012-11-10 06:29:19 +0000
4@@ -1,7 +1,7 @@
5 # UpdateList.py
6 # -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
7 #
8-# Copyright (c) 2004-2012 Canonical
9+# Copyright (c) 2004-2008 Canonical
10 #
11 # Author: Michael Vogt <mvo@debian.org>
12 #
13@@ -22,53 +22,103 @@
14
15 from __future__ import print_function
16
17+import warnings
18+warnings.filterwarnings("ignore", "Accessed deprecated property",
19+ DeprecationWarning)
20+
21 from gettext import gettext as _
22-
23-import apt
24-import logging
25 import operator
26-import random
27+import itertools
28 import subprocess
29+import os
30+import glob
31 import sys
32
33-
34-class UpdateOrigin(object):
35+from gi.repository import Gio
36+
37+APP_INSTALL_PATH = "/usr/share/app-install/desktop"
38+
39+class UpdateGroup():
40+ def __init__(self, core_pkgs, name, icon):
41+ self._core_pkgs = set(core_pkgs)
42+ self._extra_pkgs = set()
43+ self.name = name
44+ self.icon = icon
45+
46+ @property
47+ def pkgs(self):
48+ all_pkgs = []
49+ all_pkgs.extend(self._core_pkgs)
50+ all_pkgs.extend(self._extra_pkgs)
51+ return sorted(all_pkgs, key=operator.attrgetter('name'))
52+
53+ def add_extra(self, extra_pkg):
54+ if self._contains(extra_pkg):
55+ return True
56+ elif self._is_dependency(extra_pkg):
57+ self._extra_pkgs.add(extra_pkg)
58+ return True
59+ else:
60+ return False
61+
62+ def _contains(self, pkg):
63+ return pkg in self._core_pkgs or pkg in self._extra_pkgs
64+
65+ def _is_dependency(self, pkg):
66+ for core_pkg in self._core_pkgs:
67+ candidate = core_pkg.candidate
68+ dependencies = candidate.get_dependencies('Depends', 'Recommends')
69+ for dependency_pkg in itertools.chain.from_iterable(dependencies):
70+ if dependency_pkg.name == pkg.name:
71+ return True
72+ return False
73+
74+ def packages_are_selected(self):
75+ for pkg in self.pkgs:
76+ if pkg.marked_install or pkg.marked_upgrade:
77+ return True
78+ return False
79+
80+ def selection_is_inconsistent(self):
81+ pkgs_installing = [pkg for pkg in self.pkgs
82+ if pkg.marked_install or pkg.marked_upgrade]
83+ return (len(pkgs_installing) > 0 and
84+ len(pkgs_installing) < len(self.pkgs))
85+
86+ def get_total_size(self):
87+ size = 0
88+ for pkg in self.pkgs:
89+ size += getattr(pkg.candidate, "size", 0)
90+ return size
91+
92+
93+class UpdateApplicationGroup(UpdateGroup):
94+ def __init__(self, pkgs, application):
95+ name = application.get_display_name()
96+ icon = application.get_icon()
97+ super(UpdateApplicationGroup, self).__init__(pkgs, name, icon)
98+
99+
100+class UpdateSystemGroup(UpdateGroup):
101+ def __init__(self, system_pkgs):
102+ name = _("Ubuntu base")
103+ icon = Gio.ThemedIcon.new("distributor-logo")
104+ super(UpdateSystemGroup, self).__init__(system_pkgs, name, icon)
105+
106+
107+class UpdateOrigin():
108 def __init__(self, desc, importance):
109 self.packages = []
110 self.importance = importance
111 self.description = desc
112
113
114-class OriginsImportance:
115- # filed in by us
116- SECURITY = 10
117- UPDATES = 9
118- PROPOSED = 8
119- BACKPORTS = 7
120- ARCHIVE = 6
121- # this is filed in by MyCache
122- OTHER = 0
123- # this is used by us
124- OTHER_UNKNOWN = -1
125-
126-
127-class UpdateList(object):
128+class UpdateList():
129 """
130 class that contains the list of available updates in
131 self.pkgs[origin] where origin is the user readable string
132 """
133
134- # the key in the debian/control file used to add the phased
135- # updates percentage
136- PHASED_UPDATES_KEY = "Phased-Update-Percentage"
137-
138- # the file that contains the uniq machine id
139- UNIQ_MACHINE_ID_FILE = "/var/lib/dbus/machine-id"
140-
141- # the configuration key to turn phased-updates always on
142- ALWAYS_INCLUDE_PHASED_UPDATES = (
143- "Update-Manager::Always-Include-Phased-Updates")
144-
145 def __init__(self, parent):
146 # a map of packages under their origin
147 try:
148@@ -81,59 +131,64 @@
149 "you are using.") % e)
150 sys.exit(1)
151 self.distUpgradeWouldDelete = 0
152- self.pkgs = {}
153+ self.pkg_groups = []
154 self.num_updates = 0
155- self.matcher = self.initMatcher(dist)
156- self.random = random.Random()
157- # a stable machine uniq id
158- with open(self.UNIQ_MACHINE_ID_FILE) as f:
159- self.machine_uniq_id = f.read()
160-
161- def initMatcher(self, dist):
162- # (origin, archive, description, importance)
163- matcher_templates = [
164- ("%s-security" % dist, "Ubuntu", _("Important security updates"),
165- OriginsImportance.SECURITY),
166- ("%s-updates" % dist, "Ubuntu", _("Recommended updates"),
167- OriginsImportance.UPDATES),
168- ("%s-proposed" % dist, "Ubuntu", _("Proposed updates"),
169- OriginsImportance.PROPOSED),
170- ("%s-backports" % dist, "Ubuntu", _("Backports"),
171- OriginsImportance.BACKPORTS),
172- (dist, "Ubuntu", _("Distribution updates"),
173- OriginsImportance.ARCHIVE),
174- (None, None, _("Other updates"), OriginsImportance.OTHER_UNKNOWN)
175- ]
176- matcher = {}
177- for (origin, archive, desc, importance) in matcher_templates:
178- matcher[(origin, archive)] = UpdateOrigin(desc, importance)
179- return matcher
180-
181- def is_ignored_phased_update(self, pkg):
182- """ This will test if the pkg is a phased updated and if
183- it needs to get installed or ignored.
184-
185- :return: True if the updates should be ignored
186- """
187- # allow the admin to override this
188- if apt.apt_pkg.config.find_b(
189- self.ALWAYS_INCLUDE_PHASED_UPDATES, False):
190- return False
191-
192- if self.PHASED_UPDATES_KEY in pkg.candidate.record:
193- # its important that we always get the same result on
194- # multiple runs of the update-manager, so we need to
195- # feed a seed that is a combination of the pkg/ver/machine
196- self.random.seed("%s-%s-%s" % (
197- pkg.name, pkg.candidate.version,
198- self.machine_uniq_id))
199- threshold = pkg.candidate.record[self.PHASED_UPDATES_KEY]
200- percentage = self.random.randint(0, 100)
201- if percentage > int(threshold):
202- logging.info("holding back phased update (%s < %s)" % (
203- threshold, percentage))
204- return True
205- return False
206+ data_dirs = os.environ['XDG_DATA_DIRS'].split(':')
207+ self.application_dirs = [os.path.join(base, 'applications')
208+ for base in data_dirs]
209+ self.current_desktop = os.environ.get('XDG_CURRENT_DESKTOP')
210+
211+ def _file_is_application(self, file_path):
212+ file_path = os.path.abspath(file_path)
213+ is_application = False
214+ for app_dir in self.application_dirs:
215+ is_application = is_application or file_path.startswith(app_dir)
216+ extension = os.path.splitext(file_path)[1]
217+ is_application = is_application and (extension == '.desktop')
218+ return is_application
219+
220+ def _rate_application_for_package(self, application, pkg):
221+ score = 0
222+ desktop_file = os.path.basename(application.get_filename())
223+ application_id = os.path.splitext(desktop_file)[0]
224+
225+ if application.should_show():
226+ score += 1
227+
228+ if application_id == pkg.name:
229+ score += 5
230+
231+ return score
232+
233+ def _get_application_for_package(self, pkg):
234+ desktop_files = []
235+ rated_applications = []
236+
237+ for installed_file in pkg.installed_files:
238+ if self._file_is_application(installed_file):
239+ desktop_files.append(installed_file)
240+
241+ app_install_pattern = os.path.join(APP_INSTALL_PATH, '%s:*' % pkg.name)
242+ for desktop_file in glob.glob(app_install_pattern):
243+ desktop_files.append(desktop_file)
244+
245+ for desktop_file in desktop_files:
246+ try:
247+ application = Gio.DesktopAppInfo \
248+ .new_from_filename(desktop_file)
249+ application.set_desktop_env(self.current_desktop)
250+ except Exception as e:
251+ print("Error loading .desktop file %s: %s" % installed_file, e)
252+ continue
253+ score = self._rate_application_for_package(application, pkg)
254+ if score > 0:
255+ rated_applications.append((score, application))
256+
257+ rated_applications.sort(key=lambda app: app[0], reverse=True)
258+ if len(rated_applications) > 0:
259+ return rated_applications[0][1]
260+ else:
261+ return None
262
263 def update(self, cache):
264 self.held_back = []
265@@ -141,9 +196,11 @@
266 # do the upgrade
267 self.distUpgradeWouldDelete = cache.saveDistUpgrade()
268
269- #dselect_upgrade_origin = UpdateOrigin(_("Previous selected"), 1)
270+ upgrade_pkgs = []
271+ pkgs_by_source = {}
272+ ungrouped_pkgs = []
273
274- # sort by origin
275+ # Find all upgradable packages
276 for pkg in cache:
277 if pkg.is_upgradable or pkg.marked_install:
278 if getattr(pkg.candidate, "origins", None) is None:
279@@ -152,22 +209,40 @@
280 print("WARNING: upgradable but no candidate.origins?!?: ",
281 pkg.name)
282 continue
283- # check where the package belongs
284- origin_node = cache.match_package_origin(pkg, self.matcher)
285-
286- # see if its a phased update and *not* a security update
287- # or shadowing a security update
288- if (origin_node.importance != OriginsImportance.SECURITY and
289- self.is_ignored_phased_update(pkg)):
290- continue
291-
292- if origin_node not in self.pkgs:
293- self.pkgs[origin_node] = []
294- self.pkgs[origin_node].append(pkg)
295+
296+ upgrade_pkgs.append(pkg)
297 self.num_updates = self.num_updates + 1
298+
299 if pkg.is_upgradable and not (pkg.marked_upgrade or
300 pkg.marked_install):
301 self.held_back.append(pkg.name)
302- for l in self.pkgs.keys():
303- self.pkgs[l].sort(key=operator.attrgetter("name"))
304+
305+ # Index packages by source package name
306+ for pkg in upgrade_pkgs:
307+ srcpkg = pkg.candidate.source_name
308+ pkgs_by_source.setdefault(srcpkg, []).append(pkg)
309+
310+ for srcpkg, pkgs in pkgs_by_source.items():
311+ for pkg in pkgs:
312+ app = self._get_application_for_package(pkg)
313+ if app is not None:
314+ app_group = UpdateApplicationGroup([pkg], app)
315+ self.pkg_groups.append(app_group)
316+ else:
317+ ungrouped_pkgs.append(pkg)
318+ self.pkg_groups.sort(key=operator.attrgetter('name'))
319+
320+ # Stick together packages and their immediate dependencies
321+ for pkg in upgrade_pkgs:
322+ for group in self.pkg_groups:
323+ result = group.add_extra(pkg)
324+ if result and pkg in ungrouped_pkgs:
325+ ungrouped_pkgs.remove(pkg)
326+
327+ # FIXME: system_group should only have packages that are dependencies
328+ # of ubuntu-meta
329+ system_group = UpdateSystemGroup(ungrouped_pkgs)
330+ self.pkg_groups.append(system_group)
331+
332 self.keepcount = cache._depcache.keep_count
333+
334
335=== modified file 'UpdateManager/UpdatesAvailable.py'
336--- UpdateManager/UpdatesAvailable.py 2012-10-10 08:13:13 +0000
337+++ UpdateManager/UpdatesAvailable.py 2012-11-10 06:29:19 +0000
338@@ -70,10 +70,12 @@
339
340 # FIXME:
341 # - kill "all_changes" and move the changes into the "Update" class
342+# - screen reader does not read update toggle state
343+# - screen reader does not say "Downloaded" for downloaded updates
344
345 # list constants
346-(LIST_CONTENTS, LIST_NAME, LIST_PKG,
347- LIST_ORIGIN, LIST_TOGGLE_CHECKED) = range(5)
348+(LIST_NAME, LIST_UPDATE_DATA, LIST_SIZE, LIST_TOGGLE_ACTIVE) = range(4)
349+#LIST_UPDATE_DATA is a tuple of (is_toplevel, group, pkg)
350
351 # NetworkManager enums
352 from .Core.roam import NetworkManagerHelper
353@@ -134,38 +136,71 @@
354 self.update_close_button()
355
356 # the treeview (move into it's own code!)
357- self.store = Gtk.ListStore(str, str, GObject.TYPE_PYOBJECT,
358- GObject.TYPE_PYOBJECT, bool)
359+ self.store = Gtk.TreeStore(str, GObject.TYPE_PYOBJECT, str, bool)
360 self.treeview_update.set_model(self.store)
361- self.treeview_update.set_headers_clickable(True)
362+
363+ restart_icon_renderer = Gtk.CellRendererPixbuf()
364+ restart_icon_renderer.set_property("xpad", 4)
365+ restart_icon_renderer.set_property("ypad", 2)
366+ restart_icon_renderer.set_property("stock-size", Gtk.IconSize.MENU)
367+ restart_icon_renderer.set_property("follow-state", True)
368+ restart_column = Gtk.TreeViewColumn(None, restart_icon_renderer)
369+ restart_column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
370+ self.treeview_update.append_column(restart_column)
371+ restart_column.set_cell_data_func(restart_icon_renderer,
372+ self.restart_icon_renderer_data_func)
373+
374+ pkg_column = Gtk.TreeViewColumn()
375+ pkg_column.set_title(_("Install"))
376+ pkg_column.set_property("spacing", 4)
377+ pkg_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
378+ pkg_column.set_expand(True)
379+ self.treeview_update.append_column(pkg_column)
380+
381+ pkg_toggle_renderer = Gtk.CellRendererToggle()
382+ pkg_toggle_renderer.set_property("ypad", 2)
383+ pkg_toggle_renderer.connect("toggled", self.on_update_toggled)
384+ pkg_column.pack_start(pkg_toggle_renderer, False)
385+ pkg_column.add_attribute(pkg_toggle_renderer,
386+ 'active', LIST_TOGGLE_ACTIVE)
387+ pkg_column.set_cell_data_func(pkg_toggle_renderer,
388+ self.pkg_toggle_renderer_data_func)
389+
390+ pkg_icon_renderer = Gtk.CellRendererPixbuf()
391+ pkg_icon_renderer.set_property("ypad", 2)
392+ pkg_icon_renderer.set_property("stock-size", Gtk.IconSize.MENU)
393+ pkg_column.pack_start(pkg_icon_renderer, False)
394+ pkg_column.set_cell_data_func(pkg_icon_renderer,
395+ self.pkg_icon_renderer_data_func)
396+
397+ pkg_label_renderer = Gtk.CellRendererText()
398+ pkg_label_renderer.set_property("ypad", 2)
399+ pkg_column.pack_start(pkg_label_renderer, True)
400+ pkg_column.set_cell_data_func(pkg_label_renderer,
401+ self.pkg_label_renderer_data_func)
402+
403+ size_renderer = Gtk.CellRendererText()
404+ size_renderer.set_property("xpad", 6)
405+ size_renderer.set_property("ypad", 0)
406+ size_renderer.set_property("xalign", 1)
407+ # 1.0/1.2 == PANGO.Scale.SMALL. Constant is not (yet) introspected.
408+ size_renderer.set_property("scale", 1.0/1.2)
409+ size_column = Gtk.TreeViewColumn(_("Download"), size_renderer,
410+ text=LIST_SIZE)
411+ size_column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
412+ self.treeview_update.append_column(size_column)
413+
414+ self.treeview_update.set_headers_visible(True)
415+ self.treeview_update.set_headers_clickable(False)
416 self.treeview_update.set_direction(Gtk.TextDirection.LTR)
417-
418- tr = Gtk.CellRendererText()
419- tr.set_property("xpad", 6)
420- tr.set_property("ypad", 6)
421- cr = Gtk.CellRendererToggle()
422- cr.set_property("activatable", True)
423- cr.set_property("xpad", 6)
424- cr.connect("toggled", self.toggled)
425-
426- column_install = Gtk.TreeViewColumn(_("Install"), cr,
427- active=LIST_TOGGLE_CHECKED)
428- column_install.set_cell_data_func(cr, self.install_column_view_func)
429- column = Gtk.TreeViewColumn(_("Name"), tr, markup=LIST_CONTENTS)
430- column.set_resizable(True)
431-
432- column_install.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
433- column_install.set_fixed_width(30)
434- column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
435- column.set_fixed_width(100)
436 self.treeview_update.set_fixed_height_mode(False)
437-
438- self.treeview_update.append_column(column_install)
439- column_install.set_visible(True)
440- self.treeview_update.append_column(column)
441+ self.treeview_update.set_expander_column(pkg_column)
442 self.treeview_update.set_search_column(LIST_NAME)
443 self.treeview_update.connect("button-press-event",
444- self.show_context_menu)
445+ self.on_treeview_button_press)
446+ # Line up row details under expanders.
447+ # FIXME: This is arbitrary and should happen in the Gtk theme itself
448+ self.treeview_update.set_level_indentation(12)
449
450 # setup the help viewer and disable the help button if there
451 # is no viewer available
452@@ -210,26 +245,83 @@
453 self.button_close.set_use_underline(False)
454
455 def install_all_updates(self, menu, menuitem, data):
456- self.select_all_updgrades(None)
457+ self.select_all_upgrades(None)
458 self.on_button_install_clicked(None)
459
460- def install_column_view_func(self, cell_layout, renderer, model, iter,
461- data):
462- pkg = model.get_value(iter, LIST_PKG)
463- if pkg is None:
464- renderer.set_property("activatable", True)
465- return
466- current_state = renderer.get_property("active")
467- to_install = pkg.marked_install or pkg.marked_upgrade
468- renderer.set_property("active", to_install)
469- # we need to update the store as well to ensure orca knowns
470- # about state changes (it will not read view_func changes)
471- if to_install != current_state:
472- self.store[iter][LIST_TOGGLE_CHECKED] = to_install
473- if pkg.name in self.list.held_back:
474- renderer.set_property("activatable", False)
475- else:
476- renderer.set_property("activatable", True)
477+ def restart_icon_renderer_data_func(self, cell_layout, renderer, model,
478+ iter, data):
479+ # List of packages we know require a restart.
480+ # Hack to test the feature while packages lack the needed metadata.
481+ # FIXME: Remove this soon.
482+ restart_sourcepkgs = ['linux-meta']
483+
484+ def pkg_requires_restart(pkg):
485+ restart_condition = pkg.candidate.record.get('XB-Restart-Required')
486+ restart_override = pkg.candidate.source_name in restart_sourcepkgs
487+ return restart_condition == 'system' or restart_override
488+
489+ is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA)
490+ path = model.get_path(iter)
491+
492+ requires_restart = False
493+ if is_toplevel:
494+ if not self.treeview_update.row_expanded(path):
495+ # A package in the group requires restart
496+ for group_pkg in group.pkgs:
497+ if pkg_requires_restart(group_pkg):
498+ requires_restart = True
499+ break
500+ else:
501+ requires_restart = pkg_requires_restart(pkg)
502+
503+ # FIXME: Non-standard, incorrect icon name (from app category).
504+ # Theme support for what we want seems to be lacking.
505+ if requires_restart:
506+ restart_icon_names = ['view-refresh-symbolic',
507+ 'system-restart',
508+ 'system-reboot']
509+ gicon = Gio.ThemedIcon.new_from_names(restart_icon_names)
510+ else:
511+ gicon = None
512+ renderer.set_property("gicon", gicon)
513+
514+ def pkg_toggle_renderer_data_func(self, cell_layout, renderer, model, iter, data):
515+ is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA)
516+
517+ if is_toplevel:
518+ activatable = True
519+ inconsistent = group.selection_is_inconsistent()
520+ elif pkg:
521+ activatable = pkg.name not in self.list.held_back
522+ inconsistent = False
523+
524+ # Assumes the "active" attribute is already set, tied directly to LIST_TOGGLE_ACTIVE
525+ renderer.set_property("activatable", activatable)
526+ renderer.set_property("inconsistent", inconsistent)
527+
528+ def pkg_icon_renderer_data_func(self, cell_layout, renderer, model, iter, data):
529+ is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA)
530+
531+ if is_toplevel:
532+ gicon = group.icon
533+ else:
534+ gicon = Gio.ThemedIcon.new("package")
535+
536+ renderer.set_property("gicon", gicon)
537+
538+ def pkg_label_renderer_data_func(self, cell_layout, renderer, model, iter, data):
539+ is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA)
540+
541+ if is_toplevel:
542+ markup = "<b>%s</b>" % group.name
543+ else:
544+ pkg_name = xml.sax.saxutils.escape(pkg.name)
545+ if not pkg.is_installed:
546+ markup = _("%s (New install)") % pkg_name
547+ else:
548+ markup = pkg_name
549+
550+ renderer.set_property("markup", markup)
551
552 def set_changes_buffer(self, changes_buffer, text, name, srcpkg):
553 changes_buffer.set_text("")
554@@ -264,7 +356,7 @@
555 iter = model.get_iter(path)
556
557 # set descr
558- pkg = model.get_value(iter, LIST_PKG)
559+ is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA)
560 if (pkg is None or pkg.candidate is None or
561 pkg.candidate.description is None):
562 changes_buffer = self.textview_changes.get_buffer()
563@@ -350,7 +442,7 @@
564 if changes:
565 self.set_changes_buffer(changes_buffer, changes, name, srcpkg)
566
567- def show_context_menu(self, widget, event):
568+ def on_treeview_button_press(self, widget, event):
569 """
570 Show a context menu if a right click was performed on an update entry
571 """
572@@ -361,10 +453,13 @@
573 self.menu = menu = Gtk.Menu()
574 item_select_none = \
575 Gtk.MenuItem.new_with_mnemonic(_("_Deselect All"))
576- item_select_none.connect("activate", self.select_none_updgrades)
577+ item_select_none.connect("activate", self.select_none_upgrades)
578 menu.append(item_select_none)
579+ num_updates = self.cache.install_count
580+ if num_updates == 0:
581+ item_select_none.set_property("sensitive", False)
582 item_select_all = Gtk.MenuItem.new_with_mnemonic(_("Select _All"))
583- item_select_all.connect("activate", self.select_all_updgrades)
584+ item_select_all.connect("activate", self.select_all_upgrades)
585 menu.append(item_select_all)
586 menu.show_all()
587 menu.popup_for_device(
588@@ -373,35 +468,35 @@
589 return True
590
591 # we need this for select all/unselect all
592- def _toggle_origin_headers(self, new_selection_value):
593- """ small helper that will set/unset the origin headers
594+ def _toggle_group_headers(self, new_selection_value):
595+ """ small helper that will set/unset the group headers
596 """
597 model = self.treeview_update.get_model()
598 for row in model:
599- if not model.get_value(row.iter, LIST_PKG):
600- model.set_value(row.iter, LIST_TOGGLE_CHECKED,
601- new_selection_value)
602+ is_toplevel, group, pkg = model.get_value(row.iter, LIST_UPDATE_DATA)
603+ if is_toplevel:
604+ model.set_value(row.iter, LIST_TOGGLE_ACTIVE, new_selection_value)
605
606- def select_all_updgrades(self, widget):
607+ def select_all_upgrades(self, widget):
608 """
609 Select all updates
610 """
611 self.setBusy(True)
612 self.cache.saveDistUpgrade()
613- self._toggle_origin_headers(True)
614+ self._toggle_group_headers(True)
615 self.treeview_update.queue_draw()
616- self.refresh_updates_count()
617+ self.updates_changed()
618 self.setBusy(False)
619
620- def select_none_updgrades(self, widget):
621+ def select_none_upgrades(self, widget):
622 """
623 Select none updates
624 """
625 self.setBusy(True)
626 self.cache.clear()
627- self._toggle_origin_headers(False)
628+ self._toggle_group_headers(False)
629 self.treeview_update.queue_draw()
630- self.refresh_updates_count()
631+ self.updates_changed()
632 self.setBusy(False)
633
634 def setBusy(self, flag):
635@@ -417,7 +512,17 @@
636 while Gtk.events_pending():
637 Gtk.main_iteration()
638
639- def refresh_updates_count(self):
640+ def _mark_selected_updates(self):
641+ def foreach_cb(model, path, iter, data):
642+ is_toplevel, group, pkg = model.get_value(iter, LIST_UPDATE_DATA)
643+ if is_toplevel:
644+ active = group.packages_are_selected()
645+ elif pkg:
646+ active = pkg.marked_install or pkg.marked_upgrade
647+ model.set_value(iter, LIST_TOGGLE_ACTIVE, active)
648+ self.store.foreach(foreach_cb, None)
649+
650+ def _refresh_updates_count(self):
651 self.button_install.set_sensitive(self.cache.install_count)
652 try:
653 inst_count = self.cache.install_count
654@@ -458,10 +563,15 @@
655 self.hbox_downsize.show()
656 self.vbox_alerts.show()
657
658+ def updates_changed(self):
659+ self._mark_selected_updates()
660+ self._refresh_updates_count()
661+
662 def update_count(self):
663 """activate or disable widgets and show dialog texts correspoding to
664 the number of available updates"""
665- self.refresh_updates_count()
666+ self.updates_changed()
667+ num_updates = self.cache.install_count
668
669 text_header = None
670 text_desc = None
671@@ -571,14 +681,14 @@
672 # can deal with dialup connections properly
673 if state in NetworkManagerHelper.NM_STATE_CONNECTING_LIST:
674 self.label_offline.set_text(_("Connecting..."))
675- self.refresh_updates_count()
676+ self.updates_changed()
677 self.hbox_offline.show()
678 self.vbox_alerts.show()
679 self.connected = False
680 # in doubt (STATE_UNKNOWN), assume connected
681 elif (state in NetworkManagerHelper.NM_STATE_CONNECTED_LIST or
682 state == NetworkManagerHelper.NM_STATE_UNKNOWN):
683- self.refresh_updates_count()
684+ self.updates_changed()
685 self.hbox_offline.hide()
686 self.connected = True
687 # trigger re-showing the current app to get changelog info (if
688@@ -588,7 +698,7 @@
689 self.connected = False
690 self.label_offline.set_text(_("You may not be able to check for "
691 "updates or download new updates."))
692- self.refresh_updates_count()
693+ self.updates_changed()
694 self.hbox_offline.show()
695 self.vbox_alerts.show()
696
697@@ -611,45 +721,15 @@
698 self.hbox_on_3g.hide()
699 self.hbox_roaming.hide()
700
701- def row_activated(self, treeview, path, column):
702- iter = self.store.get_iter(path)
703-
704- pkg = self.store.get_value(iter, LIST_PKG)
705- origin = self.store.get_value(iter, LIST_ORIGIN)
706- if pkg is not None:
707- return
708- self.toggle_from_origin(pkg, origin, True)
709-
710- def toggle_from_origin(self, pkg, origin, select_all=True):
711- self.setBusy(True)
712- actiongroup = apt_pkg.ActionGroup(self.cache._depcache)
713- for pkg in self.list.pkgs[origin]:
714- if pkg.marked_install or pkg.marked_upgrade:
715- #print("marking keep: ", pkg.name)
716- pkg.mark_keep()
717- elif not (pkg.name in self.list.held_back):
718- #print("marking install: ", pkg.name)
719- pkg.mark_install(auto_fix=False, auto_inst=False)
720- # check if we left breakage
721- if self.cache._depcache.broken_count:
722- Fix = apt_pkg.ProblemResolver(self.cache._depcache)
723- Fix.resolve_by_keep()
724- self.refresh_updates_count()
725- self.treeview_update.queue_draw()
726- del actiongroup
727- self.setBusy(False)
728-
729- def toggled(self, renderer, path):
730+ def on_update_toggled(self, renderer, path):
731 """ a toggle button in the listview was toggled """
732 iter = self.store.get_iter(path)
733- pkg = self.store.get_value(iter, LIST_PKG)
734- origin = self.store.get_value(iter, LIST_ORIGIN)
735+ is_toplevel, group, pkg = self.store.get_value(iter, LIST_UPDATE_DATA)
736 # make sure that we don't allow to toggle deactivated updates
737 # this is needed for the call by the row activation callback
738- if pkg is None:
739- toggled_value = not self.store.get_value(iter, LIST_TOGGLE_CHECKED)
740- self.toggle_from_origin(pkg, origin, toggled_value)
741- self.store.set_value(iter, LIST_TOGGLE_CHECKED, toggled_value)
742+ if is_toplevel:
743+ self.toggle_from_group(group)
744+ self.updates_changed()
745 self.treeview_update.queue_draw()
746 return
747 if pkg is None or pkg.name in self.list.held_back:
748@@ -666,8 +746,8 @@
749 pkg.mark_install()
750 except SystemError:
751 pass
752+ self.updates_changed()
753 self.treeview_update.queue_draw()
754- self.refresh_updates_count()
755 self.setBusy(False)
756
757 def on_treeview_update_row_activated(self, treeview, path, column, *args):
758@@ -675,7 +755,28 @@
759 If an update row was activated (by pressing space), toggle the
760 install check box
761 """
762- self.toggled(None, path)
763+ self.on_update_toggled(None, path)
764+
765+ def toggle_from_group(self, group):
766+ self.setBusy(True)
767+ actiongroup = apt_pkg.ActionGroup(self.cache._depcache)
768+
769+ # Deselect all updates if any in group are selected
770+ keep_packages = group.packages_are_selected()
771+ for pkg in group.pkgs:
772+ if keep_packages:
773+ pkg.mark_keep()
774+ elif not pkg.name in self.list.held_back:
775+ pkg.mark_install(auto_fix=False, auto_inst=False)
776+
777+ # check if we left breakage
778+ if self.cache._depcache.broken_count:
779+ Fix = apt_pkg.ProblemResolver(self.cache._depcache)
780+ Fix.resolve_by_keep()
781+ self.updates_changed()
782+ self.treeview_update.queue_draw()
783+ del actiongroup
784+ self.setBusy(False)
785
786 def save_state(self):
787 """ save the state (window-size for now) """
788@@ -704,46 +805,36 @@
789 self.dl_size = 0
790
791 self.scrolledwindow_update.show()
792- origin_list = sorted(
793- self.list.pkgs, key=operator.attrgetter("importance"),
794- reverse=True)
795- for origin in origin_list:
796- self.store.append(['<b><big>%s</big></b>' % origin.description,
797- origin.description, None, origin, True])
798- for pkg in self.list.pkgs[origin]:
799- name = xml.sax.saxutils.escape(pkg.name)
800- if not pkg.is_installed:
801- name += _(" (New install)")
802- summary = xml.sax.saxutils.escape(getattr(pkg.candidate,
803- "summary", ""))
804- if self.summary_before_name:
805- contents = "%s\n<small>%s</small>" % (summary, name)
806- else:
807- contents = "<b>%s</b>\n<small>%s</small>" % (name, summary)
808- #TRANSLATORS: the b stands for Bytes
809- size = _("(Size: %s)") % humanize_size(getattr(pkg.candidate,
810- "size", 0))
811- installed_version = getattr(pkg.installed, "version", None)
812- candidate_version = getattr(pkg.candidate, "version", None)
813- if installed_version is not None:
814- version = _("From version %(old_version)s "
815- "to %(new_version)s") % {
816- "old_version": installed_version,
817- "new_version": candidate_version}
818- else:
819- version = _("Version %s") % candidate_version
820- if self.show_versions:
821- contents = "%s\n<small>%s %s</small>" % (contents, version,
822- size)
823- else:
824- contents = "%s <small>%s</small>" % (contents, size)
825- self.store.append([contents, pkg.name, pkg, None, True])
826+
827+ # add update groups and packages to self.store. Each row contains:
828+ # row label (for screen reader),
829+ # update data tuple (is_toplevel, group object, package object),
830+ # update size,
831+ # update selection state
832+ for group in self.list.pkg_groups:
833+ group_row = [
834+ group.name,
835+ (True, group, None),
836+ humanize_size(group.get_total_size()),
837+ True
838+ ]
839+ tree_parent = self.store.append(None, group_row)
840+
841+ for pkg in group.pkgs:
842+ pkg_row = [
843+ pkg.name,
844+ (False, group, pkg),
845+ humanize_size(getattr(pkg.candidate, "size", 0)),
846+ True
847+ ]
848+ self.store.append(tree_parent, pkg_row)
849+
850 self.treeview_update.set_model(self.store)
851 self.update_count()
852 self.setBusy(False)
853 while Gtk.events_pending():
854 Gtk.main_iteration()
855- self.refresh_updates_count()
856+ self.updates_changed()
857 return False
858
859 def main(self):

Subscribers

People subscribed via source and target branches

to status/vote changes: