Merge ~ddstreet/software-properties:lp645404 into software-properties:ubuntu/master

Proposed by Dan Streetman
Status: Rejected
Rejected by: Dan Streetman
Proposed branch: ~ddstreet/software-properties:lp645404
Merge into: software-properties:ubuntu/master
Diff against target: 3367 lines (+1764/-1064)
22 files modified
add-apt-repository (+212/-175)
debian/control (+2/-0)
debian/manpages/add-apt-repository.1 (+107/-34)
debian/tests/add-apt-repository-archive (+56/-0)
debian/tests/add-apt-repository-cloud (+59/-0)
debian/tests/add-apt-repository-ppa (+71/-0)
debian/tests/control (+10/-2)
dev/null (+0/-167)
softwareproperties/SoftwareProperties.py (+17/-35)
softwareproperties/cloudarchive.py (+100/-84)
softwareproperties/gtk/DialogCacheOutdated.py (+1/-2)
softwareproperties/gtk/SoftwarePropertiesGtk.py (+1/-2)
softwareproperties/ppa.py (+147/-427)
softwareproperties/shortcuthandler.py (+617/-0)
softwareproperties/shortcuts.py (+19/-29)
softwareproperties/sourceslist.py (+56/-0)
softwareproperties/uri.py (+36/-0)
tests/test_aptsources.py (+1/-1)
tests/test_dbus.py (+45/-66)
tests/test_pyflakes.py (+0/-1)
tests/test_shortcuts.py (+206/-38)
tests/test_sp.py (+1/-1)
Reviewer Review Type Date Requested Status
James Page Pending
Corey Bryant Pending
Julian Andres Klode Pending
Ubuntu Core Development Team Pending
Review via email: mp+367276@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Dan Streetman (ddstreet) wrote :

RFC, in-progress git repo

Revision history for this message
Dan Streetman (ddstreet) wrote :

I think this is ready for at least initial review. Note this does not include --remote support.

Revision history for this message
Julian Andres Klode (juliank) wrote :

I don't like this commit, it makes it harder to read for basically no benefit:
    move 'datadir' into kwargs, set default in SoftwareProperties

same for rootdir moving into kwargs in the next commit. kwargs should be avoided as much as humanely possible. It prevents a lot of static checks (especially type checks get harder, and I'd love to add some mypy annotations at some point).

The removal of the Options stuff I don't like very much, I felt it was cleaner before.

Refactoring of ShortCutHandler I can't really follow, it's too big.

Revision history for this message
Dan Streetman (ddstreet) wrote :

> I don't like this commit, it makes it harder to read for basically no benefit:
> move 'datadir' into kwargs, set default in SoftwareProperties
>
> same for rootdir moving into kwargs in the next commit. kwargs should be
> avoided as much as humanely possible. It prevents a lot of static checks
> (especially type checks get harder, and I'd love to add some mypy annotations
> at some point).
>
> The removal of the Options stuff I don't like very much, I felt it was cleaner
> before.
>
>
> Refactoring of ShortCutHandler I can't really follow, it's too big.

Here's a different approach that makes the absolute minimum changes to SoftwareProperties, and splits up much of the rewriting to possibly be easier for you to review.

Just an initial draft, I need to go over the commits again and check the test updates, so it's not ready to merge yet but shoudl be ready for general review.

Revision history for this message
Dan Streetman (ddstreet) wrote :

Hold that review for a bit; I'm still working on getting SoftwareProperties out of add-apt-repository.

Revision history for this message
Dan Streetman (ddstreet) wrote :

Ready for review please.

Revision history for this message
Dan Streetman (ddstreet) wrote :

There was an error fetching revisions from git servers. Please try again in a few minutes. If the problem persists, contact Launchpad support.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/add-apt-repository b/add-apt-repository
index ea5f7dc..0b2109f 100755
--- a/add-apt-repository
+++ b/add-apt-repository
@@ -7,190 +7,227 @@ import os
7import sys7import sys
8import gettext8import gettext
9import locale9import locale
1010import argparse
11from softwareproperties.SoftwareProperties import SoftwareProperties, shortcut_handler11import subprocess
12from softwareproperties.shortcuts import ShortcutException12
13import aptsources13from softwareproperties.shortcuthandler import ShortcutException
14from aptsources.sourceslist import SourceEntry14from softwareproperties.shortcuts import shortcut_handler
15from optparse import OptionParser15from softwareproperties.ppa import PPAShortcutHandler
16from softwareproperties.cloudarchive import CloudArchiveShortcutHandler
17from softwareproperties.sourceslist import SourcesListShortcutHandler
18from softwareproperties.uri import URIShortcutHandler
19
20from aptsources.distro import get_distro
21from aptsources.sourceslist import (SourcesList, SourceEntry)
16from gettext import gettext as _22from gettext import gettext as _
1723
18if __name__ == "__main__":24
19 # Force encoding to UTF-8 even in non-UTF-8 locales.25class AddAptRepository(object):
20 sys.stdout = io.TextIOWrapper(26 def __init__(self):
21 sys.stdout.detach(), encoding="UTF-8", line_buffering=True)27 gettext.textdomain("software-properties")
2228 self.distro = get_distro()
23 try:29 self.sourceslist = SourcesList()
24 locale.setlocale(locale.LC_ALL, "")30 self.distro.get_sources(self.sourceslist)
25 except:31
26 pass32 def parse_args(self, args):
27 gettext.textdomain("software-properties")33 description = "Only ONE of -p, -c, -a, or old-style 'line' can be specified"
28 usage = """Usage: %prog <sourceline>34
2935 parser = argparse.ArgumentParser(description=description)
30%prog is a script for adding apt sources.list entries.36 parser.add_argument("-d", "--debug", action="store_true",
31It can be used to add any repository and also provides a shorthand37 help=_("Print debug"))
32syntax for adding a Launchpad PPA (Personal Package Archive)38 parser.add_argument("-r", "--remove", action="store_true",
33repository.39 help=_("Disable repository"))
3440 parser.add_argument("-s", "--enable-source", action="store_true",
35<sourceline> - The apt repository source line to add. This is one of:41 help=_("Allow downloading of the source packages from the repository"))
36 a complete apt line in quotes,42 parser.add_argument("-C", "--component", action="append", default=[],
37 a repo url and areas in quotes (areas defaults to 'main')43 help=_("Components to use with the repository"))
38 a PPA shortcut.44 parser.add_argument("-y", "--yes", action="store_true",
39 a distro component45 help=_("Assume yes to all queries"))
4046 parser.add_argument("-n", "--no-update", dest="update", action="store_false",
41 Examples:47 help=_("Do not update package cache after adding"))
42 apt-add-repository 'deb http://myserver/path/to/repo stable myrepo'48 parser.add_argument("-u", "--update", action="store_true", default=True,
43 apt-add-repository 'http://myserver/path/to/repo myrepo'49 help=argparse.SUPPRESS)
44 apt-add-repository 'https://packages.medibuntu.org free non-free'50 parser.add_argument("-l", "--login", action="store_true",
45 apt-add-repository http://extras.ubuntu.com/ubuntu51 help=_("Login to Launchpad."))
46 apt-add-repository ppa:user/repository52 parser.add_argument("--dry-run", action="store_true",
47 apt-add-repository ppa:user/distro/repository53 help=_("Don't actually make any changes."))
48 apt-add-repository multiverse54
4955 group = parser.add_mutually_exclusive_group()
50If --remove is given the tool will remove the given sourceline from your56 group.add_argument("-p", "--ppa",
51sources.list57 help=_("PPA to add"))
52"""58 group.add_argument("-c", "--cloud",
53 parser = OptionParser(usage)59 help=_("Cloud Archive to add"))
54 # FIXME: provide a --sources-list-file= option that60 group.add_argument("-U", "--uri",
55 # puts the line into a specific file in sources.list.d61 help=_("Archive URI to add"))
56 parser.add_option ("-m", "--massive-debug", action="store_true",62 group.add_argument("-S", "--sourceslist",
57 dest="massive_debug", default=False,63 help=_("Full sources.list entry line to add"))
58 help=_("Print a lot of debug information to the command line"))64 group.add_argument("line", nargs='*', default=[],
59 parser.add_option("-r", "--remove", action="store_true",65 help=_("sources.list line to add (deprecated)"))
60 dest="remove", default=False,66
61 help=_("remove repository from sources.list.d directory"))67 self.parser = parser
62 parser.add_option("-s", "--enable-source", action="store_true",68 self.options = self.parser.parse_args(args)
63 dest="enable_source", default=False,69
64 help=_("Allow downloading of the source packages from the repository"))70 @property
65 parser.add_option("-y", "--yes", action="store_true",71 def dry_run(self):
66 dest="assume_yes", default=False,72 return self.options.dry_run
67 help=_("Assume yes to all queries"))73
68 parser.add_option("-n", "--no-update", action="store_false",74 @property
69 dest="update", default=True,75 def enable_source(self):
70 help=_("Do not update package cache after adding"))76 return self.options.enable_source
71 parser.add_option("-u", "--update", action="store_true",77
72 dest="update", default=True,78 @property
73 help=_("Update package cache after adding (legacy option)"))79 def components(self):
74 parser.add_option("-k", "--keyserver",80 return self.options.component
75 dest="keyserver", default="",81
76 help=_("Legacy option, unused."))82 def is_components(self, comps):
77 83 if not comps:
78 (options, args) = parser.parse_args()84 return False
79 85 return set(comps.split()) <= set([comp.name for comp in self.distro.source_template.components])
80 # We prefer to run apt-get update here. The built-in update support86
81 # does not have any progress, and only works for shortcuts. Moving87 def apt_update(self):
82 # it to something like save() and using apt.progress.text would88 if self.options.update and not self.dry_run:
83 # solve the problem, but the new errors might cause problems with89 # We prefer to run apt-get update here. The built-in update support
84 # the dbus server or other users of the API. Also, it's unclear90 # does not have any progress, and only works for shortcuts. Moving
85 # how good the text progress is or how to pass it best.91 # it to something like save() and using apt.progress.text would
86 update = options.update92 # solve the problem, but the new errors might cause problems with
87 options.update = False93 # the dbus server or other users of the API. Also, it's unclear
8894 # how good the text progress is or how to pass it best.
89 if os.geteuid() != 0:95 subprocess.run(['apt-get', 'update'])
90 print(_("Error: must run as root"))96
91 sys.exit(1)97 def prompt_user(self):
9298 if self.dry_run:
93 if len(args) == 0:99 print(_("DRY-RUN mode: no modifications will be made"))
94 print(_("Error: need a repository as argument"))100 return
95 sys.exit(1)101 if not self.options.yes and sys.stdin.isatty() and not "FORCE_ADD_APT_REPOSITORY" in os.environ:
96 elif len(args) > 1:
97 print(_("Error: need a single repository as argument"))
98 sys.exit(1)
99
100 # force new ppa file to be 644 (LP: #399709)
101 os.umask(0o022)
102
103 # get the line
104 line = args[0]
105
106 # add it
107 sp = SoftwareProperties(options=options)
108 distro = aptsources.distro.get_distro()
109 distro.get_sources(sp.sourceslist)
110
111 # check if its a component that should be added/removed
112 components = [comp.name for comp in distro.source_template.components]
113 if line in components:
114 if options.remove:
115 if line in distro.enabled_comps:
116 distro.disable_component(line)
117 print(_("'%s' distribution component disabled for all sources.") % line)
118 else:
119 print(_("'%s' distribution component is already disabled for all sources.") % line)
120 sys.exit(0)
121 else:
122 if line not in distro.enabled_comps:
123 distro.enable_component(line)
124 print(_("'%s' distribution component enabled for all sources.") % line)
125 else:
126 print(_("'%s' distribution component is already enabled for all sources.") % line)
127 sys.exit(0)
128 sp.sourceslist.save()
129 if update and not options.remove:
130 os.execvp("apt-get", ["apt-get", "update"])
131 sys.exit(0)
132
133 # this wasn't a component name ('multiverse', 'backports'), so its either
134 # a actual line to be added or a shortcut.
135 try:
136 shortcut = shortcut_handler(line)
137 except ShortcutException as e:
138 print(e)
139 sys.exit(1)
140
141 # display more information about the shortcut / ppa info
142 if not options.assume_yes and shortcut.should_confirm():
143 try:
144 info = shortcut.info()
145 except ShortcutException as e:
146 print(e)
147 sys.exit(1)
148
149 print(" %s" % (info["description"] or ""))
150 print(_(" More info: %s") % str(info["web_link"]))
151 if (sys.stdin.isatty() and
152 not "FORCE_ADD_APT_REPOSITORY" in os.environ):
153 if options.remove:
154 print(_("Press [ENTER] to continue or Ctrl-c to cancel removing it."))
155 else:
156 print(_("Press [ENTER] to continue or Ctrl-c to cancel adding it."))
157 try:102 try:
158 sys.stdin.readline()103 input(_("Press [ENTER] to continue or Ctrl-c to cancel."))
159 except KeyboardInterrupt:104 except KeyboardInterrupt:
160 print("\n")105 print(_("Aborted."))
161 sys.exit(1)106 sys.exit(1)
162107
108 def prompt_user_shortcut(self, shortcut):
109 '''Display more information about the shortcut / ppa info'''
110 print(_("Repository: '%s'") % shortcut.SourceEntry().line)
111 if shortcut.description:
112 print(_("Description:"))
113 print(shortcut.description)
114 if shortcut.web_link:
115 print(_("More info: %s") % shortcut.web_link)
116 if self.options.remove:
117 print(_("Removing repository."))
118 else:
119 print(_("Adding repository."))
120 self.prompt_user()
121
122 def change_components(self):
123 for c in self.components:
124 if self.options.remove:
125 self.distro.disable_component(c)
126 print(_("Removed component %s") % c)
127 else:
128 self.distro.enable_component(c)
129 print(_("Added component %s") % c)
130 if not self.dry_run:
131 self.sourceslist.save()
132
133 def change_source(self):
134 newlist = []
135 for s in [s for s in self.sourceslist if not s.invalid and not s.disabled]:
136 if self.options.remove and s.type == self.distro.source_type:
137 s.set_enabled(False)
138 print(_("Disabled: %s") % s.str())
139 elif not self.options.remove and s.type == self.distro.binary_type:
140 s = SourceEntry(str(s))
141 s.type = self.distro.source_type
142 self.sourceslist.add(s.type, s.uri, s.dist, s.comps, comment=s.comment,
143 file=s.file, architectures=s.architectures)
144 print(_("Enabled: %s") % s.str())
145 if not self.dry_run:
146 self.sourceslist.save()
147
148 def global_change(self):
149 if self.components:
150 if self.options.remove:
151 print(_("Removing component(s) '%s' from all repositories.") % ', '.join(self.components))
152 else:
153 print(_("Adding component(s) '%s' to all repositories.") % ', '.join(self.components))
154 if self.enable_source:
155 if self.options.remove:
156 print(_("Disabling %s for all repositories.") % self.distro.source_type)
157 else:
158 print(_("Enabling %s for all repositories.") % self.distro.source_type)
159 self.prompt_user()
160 if self.components:
161 self.change_components()
162 if self.enable_source:
163 self.change_source()
164
165 def main(self, args=sys.argv[1:]):
166 self.parse_args(args)
167
168 if not self.dry_run and os.geteuid() != 0:
169 print(_("Error: must run as root"))
170 return False
171
172 line = ' '.join(self.options.line)
173 if line == '-':
174 line = sys.stdin.readline().strip()
175
176 # if 'line' is only (valid) components, handle as if only -C was used with no line
177 if self.is_components(line):
178 self.options.component += line.split()
179 line = ''
180
181 if self.options.ppa:
182 source = self.options.ppa
183 if not ':' in source:
184 source = 'ppa:' + source
185 handler = PPAShortcutHandler
186 elif self.options.cloud:
187 source = self.options.cloud
188 if not ':' in source:
189 source = 'uca:' + source
190 handler = CloudArchiveShortcutHandler
191 elif self.options.uri:
192 source = self.options.uri
193 handler = URIShortcutHandler
194 elif self.options.sourceslist:
195 source = self.options.sourceslist
196 handler = SourcesListShortcutHandler
197 elif line:
198 source = line
199 handler = shortcut_handler
200 elif self.enable_source or self.components:
201 self.global_change()
202 self.apt_update()
203 return True
204 else:
205 print(_("Error: no actions requested."))
206 self.parser.print_help()
207 return False
163208
164 if options.remove:
165 try:209 try:
166 (line, file) = shortcut.expand(210 shortcut_params = {
167 sp.distro.codename, sp.distro.id.lower())211 'login': self.options.login,
212 'enable_source': self.enable_source,
213 'dry_run': self.dry_run,
214 'components': self.components,
215 }
216 shortcut = handler(source, **shortcut_params)
168 except ShortcutException as e:217 except ShortcutException as e:
169 print(e)218 print(e)
170 sys.exit(1)219 return False
171 deb_line = sp.expand_http_line(line)
172 debsrc_line = 'deb-src' + deb_line[3:]
173 deb_entry = SourceEntry(deb_line, file)
174 debsrc_entry = SourceEntry(debsrc_line, file)
175 try:
176 sp.remove_source(deb_entry)
177 except ValueError:
178 print(_("Error: '%s' doesn't exist in a sourcelist file") % deb_line)
179 try:
180 sp.remove_source(debsrc_entry)
181 except ValueError:
182 print(_("Error: '%s' doesn't exist in a sourcelist file") % debsrc_line)
183220
184 else:221 self.prompt_user_shortcut(shortcut)
185 try:222
186 if not sp.add_source_from_shortcut(shortcut, options.enable_source):223 if self.options.remove:
187 print(_("Error: '%s' invalid") % line)224 shortcut.remove()
188 sys.exit(1)225 else:
189 except ShortcutException as e:226 shortcut.add()
190 print(e)227
191 sys.exit(1)228 self.apt_update()
229 return True
192230
193 sp.sourceslist.save()231if __name__ == '__main__':
194 if update and not options.remove:232 addaptrepo = AddAptRepository()
195 os.execvp("apt-get", ["apt-get", "update"])233 sys.exit(0 if addaptrepo.main() else 1)
196 sys.exit(0)
diff --git a/debian/control b/debian/control
index 6998d5e..9f9441f 100644
--- a/debian/control
+++ b/debian/control
@@ -21,6 +21,7 @@ Build-Depends: dbus-x11 <!nocheck>,
21 python3-distro-info <!nocheck>,21 python3-distro-info <!nocheck>,
22 python3-distutils-extra,22 python3-distutils-extra,
23 python3-gi <!nocheck>,23 python3-gi <!nocheck>,
24 python3-launchpadlib,
24 python3-mock <!nocheck>,25 python3-mock <!nocheck>,
25 python3-requests-unixsocket <!nocheck>,26 python3-requests-unixsocket <!nocheck>,
26 python3-setuptools,27 python3-setuptools,
@@ -40,6 +41,7 @@ Depends: gpg,
40 python3-apt (>=41 python3-apt (>=
41 0.6.20ubuntu16),42 0.6.20ubuntu16),
42 python3-gi,43 python3-gi,
44 python3-launchpadlib,
43 ${misc:Depends},45 ${misc:Depends},
44 ${python3:Depends}46 ${python3:Depends}
45Recommends: unattended-upgrades47Recommends: unattended-upgrades
diff --git a/debian/manpages/add-apt-repository.1 b/debian/manpages/add-apt-repository.1
index 3a195a4..93d7e94 100644
--- a/debian/manpages/add-apt-repository.1
+++ b/debian/manpages/add-apt-repository.1
@@ -4,53 +4,126 @@ add-apt-repository \- Adds a repository into the
4/etc/apt/sources.list or /etc/apt/sources.list.d 4/etc/apt/sources.list or /etc/apt/sources.list.d
5or removes an existing one5or removes an existing one
6.SH SYNOPSIS6.SH SYNOPSIS
7.B add-apt-repository \fI[OPTIONS]\fR \fIREPOSITORY\fR7.B add-apt-repository \fI[OPTIONS]\fR \fI[LINE]\fR
8.SH DESCRIPTION8.SH DESCRIPTION
9.B add-apt-repository9.B add-apt-repository
10is a script which adds an external APT repository to either10is a script which adds an external APT repository to either
11/etc/apt/sources.list or a file in /etc/apt/sources.list.d/ 11/etc/apt/sources.list or a file in /etc/apt/sources.list.d/
12or removes an already existing repository.12or removes an already existing repository.
1313
14The options supported by add-apt-repository are:14.SH OPTIONS
1515Note that the \fB--ppa\fR, \fB--cloud\fR, \fB--archive\fR, and \fBLINE\fR parameters are mutually exclusive; only one (or none) of them may be specified.
16.TP
16.B -h, --help17.B -h, --help
17Show help message and exit18Show help message and exit
1819.TP
19.B -m, --massive-debug20.B -d, --debug
20Print a lot of debug information to the command line21Print debug information to the command line
2122.TP
22.B -r, --remove23.B -r, --remove
23Remove the specified repository24Remove the specified repository; this first will disable (comment out) the matching line(s),
25and then any modified file(s) under sources.list.d/ will be removed if they contain only empty and commented lines.
2426
27Note that this performs differently when used with the \fI--enable-source\fR and/or \fI--component\fR parameters.
28Without either of those parameters, this removes the specified repository, including any \fBdeb-src\fR line(s), and all components.
29If \fI--enable-source\fR is used, this removes \fBonly\fR the 'deb-src' line(s).
30If \fI--component\fR is used, this removes \fBonly\FR the specified component(s), and only removes the repository if no components remain.
31
32If both \fI--enable-source\fR and \fI--component\fR are used with \fI--remove\fR, the actions are performed separately: the specified
33component(s) will be removed from both \fBdeb\fR and \fBdeb-src\fR lines, and \fBdeb-src\fR lines will be disabled.
34.TP
25.B -y, --yes35.B -y, --yes
26Assume yes to all queries36Assume yes to all queries
2737.TP
28.B -u, --update38.B -n, --no-update
29After adding the repository, update the package cache with packages from this repository (avoids need to apt-get update)39After adding the repository, do not update the package cache
3040.TP
31.B -k, --keyserver41.B -l, --login
32Use a custom keyserver URL instead of the default42Login to Launchpad (this is only needed for private PPAs)
3343.TP
34.B -s, --enable-source44.B -s, --enable-source
35Allow downloading of the source packages from the repository45Allow downloading of the source packages from the repository; specifically, this adds and enables a 'deb-src' line for the repostiory.
3646If this parameter is used without any repository, it will enable source for all currently existing repositories.
3747.TP
38.SH REPOSITORY STRING48.B -C, --component
39\fIREPOSITORY\fR can be either a line that can be added directly to49Which component(s) should be used with the specified repository. If not specified, this will default to 'main'.
40sources.list(5), in the form ppa:<user>/<ppa-name> for adding Personal50This may be used multiple times to specify multiple components. If this is used without any repository, it will add the
41Package Archives, or a distribution component to enable.51component(s) to all currently existing repositories.
4252.TP
43In the first form, \fIREPOSITORY\fR will just be appended to 53.B --dry-run
44/etc/apt/sources.list.54Show what would be done, but don't make any changes
4555.TP
46In the second form, ppa:<user>/<ppa-name> will be expanded to the full deb line56.B -p, --ppa
47of the PPA and added into a new file in the /etc/apt/sources.list.d/57Add an Ubuntu Launchpad Personal Package Archive.
48directory.58Must be in the format \fBppa:USER/PPA\fR, \fBUSER/PPA\fR, or \fBUSER\fR.
49The GPG public key of the newly added PPA will also be downloaded and59The \fBUSER\fR parameter should be the Launchpad team or person that owns the PPA.
50added to apt's keyring.60The \fBPPA\fR parameter should be the name of the PPA; if not provided, it defaults to 'ppa'.
5161The GPG public key of the PPA will also be downloaded and added to apt's keyring.
52In the third form, the given distribution component will be enabled for all62To add a private PPA, you must also use the \FI--login\fR parameter, and of course you must also be subscribed to the private PPA.
53sources.63.TP
64.B -c, --cloud
65Add an Ubuntu Cloud Archive.
66Must be in the format \fBcloud-archive:CANAME\fR, \fBuca:CANAME\fR, or \fBCANAME\fR.
67The \fBCANAME\fR parameter should be the name of the Cloud Archive.
68The \fBCANAME\fR parameter may optionally be suffixed with the pocket, as either \fB-updates\fR or \fB-proposed\fR.
69If not specified, the pocket defaults to \fB-updates\fR.
70.TP
71.B -U, --uri
72Add an archive, specified as a single URI.
73If the URI provided is detected to be a PPA, this will operate as if the \fI--ppa\fR parameter was used.
74.TP
75.B -S, --sourceslist
76Add an archive, specified as a full source entry line in one-line sources.list format.
77It must follow the \fIONE-LINE-STYLE\fR format as described in the \fBsources.list\fR manpage.
78If the URI provided is detected to be a PPA, this will operate as if the \fI--ppa\fR parameter was used.
79
80.SH LINE
81\fILINE\fR is a deprecated method to specify the repository to add/remove, provided only for backwards compatibility.
82It can be specified in any of the supported formats: sources.list line, plain uri, ppa shortcut, or cloud-archive shortcut.
83It can also be specified as one or more valid component(s). The script will attempt to detect which format is provided.
84
85This is not recommended as the autodetection of which repository format is intended can be ambiguous, but older
86scripts may still use this method of specifying the repository.
87
88One special case of \fILINE\fR is providing the value \fB-\fR, which will then read the \fILINE\fR from stdin.
89
90.SH DEPRECATED EXAMPLES
91.TP
92add-apt-repository -p ppa:user/repository
93.TP
94add-apt-repository -p user/repository
95.TP
96add-apt-repository -c cloud-archive:queens
97.TP
98add-apt-repository -c queens
99.TP
100add-apt-repository -S 'deb http://myserver/path/to/repo stable main'
101.TP
102add-apt-repository -U http://myserver/path/to/repo -C main
103.TP
104add-apt-repository -U https://packages.medibuntu.org -C free -C non-free
105.TP
106add-apt-repository -U http://extras.ubuntu.com/ubuntu
107.TP
108add-apt-repository -s
109.TP
110add-apt-repository -s -r
111.TP
112add-apt-repository -C universe
113.TP
114add-apt-repository -r -C multiverse
115
116.SH DEPRECATED EXAMPLES
117.TP
118add-apt-repository deb http://myserver/path/to/repo stable main
119.TP
120add-apt-repository http://myserver/path/to/repo main
121.TP
122add-apt-repository https://packages.medibuntu.org free non-free
123.TP
124add-apt-repository http://extras.ubuntu.com/ubuntu
125.TP
126add-apt-repository multiverse
54127
55.SH SEE ALSO128.SH SEE ALSO
56\fBsources.list\fR(5)129\fBsources.list\fR(5)
diff --git a/debian/tests/add-apt-repository b/debian/tests/add-apt-repository
57deleted file mode 100755130deleted file mode 100755
index 7dab076..0000000
--- a/debian/tests/add-apt-repository
+++ /dev/null
@@ -1,14 +0,0 @@
1#!/bin/sh
2set -e
3
4for locale in C.UTF-8 C
5do
6 export LC_ALL=$locale
7 echo LC_ALL=$locale test...
8 rm -f /etc/apt/sources.list.d/xnox-ubuntu-nonvirt-*.list
9 rm -f /etc/apt/trusted.gpg.d/xnox_ubuntu_nonvirt.gpg
10 add-apt-repository ppa:xnox/nonvirt --yes --no-update
11 [ -s /etc/apt/sources.list.d/xnox-ubuntu-nonvirt-*.list ]
12 [ -s /etc/apt/trusted.gpg.d/xnox_ubuntu_nonvirt.gpg ]
13 gpg -q --homedir $(mktemp -d) --no-default-keyring --keyring /etc/apt/trusted.gpg.d/xnox_ubuntu_nonvirt.gpg --fingerprint
14done
diff --git a/debian/tests/add-apt-repository-archive b/debian/tests/add-apt-repository-archive
15new file mode 1007550new file mode 100755
index 0000000..2907391
--- /dev/null
+++ b/debian/tests/add-apt-repository-archive
@@ -0,0 +1,56 @@
1#!/usr/bin/python3
2
3import contextlib
4import os
5import sys
6import subprocess
7import tempfile
8
9from aptsources.distro import get_distro
10
11
12codename=get_distro().codename
13URI='http://fake.mirror.private.com/ubuntu'
14SOURCESLIST=f'deb {URI} {codename} main'
15SOURCESLISTFILE=f'/etc/apt/sources.list.d/archive_uri-http_fake_mirror_private_com_ubuntu-{codename}.list'
16
17def run_test(archive, param, yes, noupdate, remove, locale):
18 env = os.environ.copy()
19 if locale:
20 env['LC_ALL'] = locale
21
22 with contextlib.suppress(FileNotFoundError):
23 os.remove(SOURCESLISTFILE)
24
25 cmd = f'add-apt-repository {yes} {noupdate} {param} {archive}'
26 subprocess.check_call(cmd.split(), env=env)
27
28 if not os.path.exists(SOURCESLISTFILE) or os.path.getsize(SOURCESLISTFILE) == 0:
29 print("Missing/empty sources.list file: %s" % SOURCESLISTFILE)
30 sys.exit(1)
31
32 cmd = f'add-apt-repository {remove} {yes} {noupdate} {param} {archive}'
33 subprocess.check_call(cmd.split(), env=env)
34
35 if os.path.exists(SOURCESLISTFILE):
36 print("sources.list file not removed: %s" % SOURCESLISTFILE)
37 with open(SOURCESLISTFILE) as f:
38 print(f.read())
39 sys.exit(1)
40
41
42for PARAM in ['-U', '--uri', '']:
43 for YES in ['-y', '--yes']:
44 for NOUPDATE in ['-n', '--no-update', '']:
45 for REMOVE in ['-r', '--remove']:
46 for LOCALE in ['', 'C', 'C.UTF-8']:
47 run_test(URI, PARAM, YES, NOUPDATE, REMOVE, LOCALE)
48
49for PARAM in ['-S', '--sourceslist', '']:
50 for YES in ['-y', '--yes']:
51 for NOUPDATE in ['-n', '--no-update', '']:
52 for REMOVE in ['-r', '--remove']:
53 for LOCALE in ['', 'C', 'C.UTF-8']:
54 run_test(SOURCESLIST, PARAM, YES, NOUPDATE, REMOVE, LOCALE)
55
56sys.exit(0)
diff --git a/debian/tests/add-apt-repository-cloud b/debian/tests/add-apt-repository-cloud
0new file mode 10075557new file mode 100755
index 0000000..93619e0
--- /dev/null
+++ b/debian/tests/add-apt-repository-cloud
@@ -0,0 +1,59 @@
1#!/usr/bin/python3
2
3import contextlib
4import os
5import sys
6import subprocess
7
8from softwareproperties.cloudarchive import RELEASE_MAP
9from aptsources.distro import get_distro
10
11codename = get_distro().codename
12uca_releases = list(filter(lambda r: RELEASE_MAP[r] == codename, RELEASE_MAP.keys()))
13
14if not uca_releases:
15 print("No UCA releases available for this Ubuntu release")
16 sys.exit(77)
17
18def run_test(caname, uca, param, yes, noupdate, remove, locale):
19 env = os.environ.copy()
20 if locale:
21 env['LC_ALL'] = locale
22
23 SOURCESLISTFILE=f'/etc/apt/sources.list.d/cloudarchive-{caname}.list'
24
25 with contextlib.suppress(FileNotFoundError):
26 os.remove(SOURCESLISTFILE)
27
28 cmd = 'apt-get -q -y remove ubuntu-cloud-keyring'
29 subprocess.run(cmd.split(), stderr=subprocess.DEVNULL, env=env)
30
31 cmd = f'add-apt-repository {yes} {noupdate} {param} {uca}'
32 subprocess.check_call(cmd.split(), env=env)
33
34 if not os.path.exists(SOURCESLISTFILE) or os.path.getsize(SOURCESLISTFILE) == 0:
35 print("Missing/empty sources.list file: %s" % SOURCESLISTFILE)
36 sys.exit(1)
37
38 cmd = 'dpkg-query -l ubuntu-cloud-keyring'
39 subprocess.check_call(cmd.split(), env=env)
40
41 cmd = f'add-apt-repository {remove} {yes} {noupdate} {param} {uca}'
42 subprocess.check_call(cmd.split(), env=env)
43
44 if os.path.exists(SOURCESLISTFILE):
45 print("sources.list file not removed: %s" % SOURCESLISTFILE)
46 with open(SOURCESLISTFILE) as f:
47 print(f.read())
48 sys.exit(1)
49
50for CANAME in uca_releases:
51 for UCA in [f'{CANAME}', f'cloud-archive:{CANAME}', f'uca:{CANAME}']:
52 for PARAM in ['-c', '--cloud', '']:
53 for YES in ['-y', '--yes']:
54 for NOUPDATE in ['-n', '--no-update', '']:
55 for REMOVE in ['-r', '--remove']:
56 for LOCALE in ['', 'C', 'C.UTF-8']:
57 run_test(CANAME, UCA, PARAM, YES, NOUPDATE, REMOVE, LOCALE)
58
59sys.exit(0)
diff --git a/debian/tests/add-apt-repository-ppa b/debian/tests/add-apt-repository-ppa
0new file mode 10075560new file mode 100755
index 0000000..fb78985
--- /dev/null
+++ b/debian/tests/add-apt-repository-ppa
@@ -0,0 +1,71 @@
1#!/usr/bin/python3
2
3import contextlib
4import os
5import sys
6import subprocess
7import tempfile
8
9from aptsources.distro import get_distro
10
11codename=get_distro().codename
12SOURCESLISTFILE=f'/etc/apt/sources.list.d/ubuntu-support-team-ubuntu-software-properties-autopkgtest-{codename}.list'
13TRUSTEDFILE='/etc/apt/trusted.gpg.d/ubuntu-support-team-ubuntu-software-properties-autopkgtest.gpg'
14PPANAME='ubuntu-support-team/software-properties-autopkgtest'
15
16def run_test(ppa, param, yes, noupdate, remove, locale):
17 env = os.environ.copy()
18 if locale:
19 env['LC_ALL'] = locale
20
21 with contextlib.suppress(FileNotFoundError):
22 os.remove(SOURCESLISTFILE)
23 with contextlib.suppress(FileNotFoundError):
24 os.remove(TRUSTEDFILE)
25
26 cmd = f'add-apt-repository {yes} {noupdate} {param} {ppa}'
27 try:
28 subprocess.check_call(cmd.split(), env=env)
29 except subprocess.CalledProcessError:
30 if not param and not ppa.startswith('ppa:'):
31 # When using 'line' instead of --ppa, the 'ppa:' prefix is required
32 return
33 raise
34
35 if not os.path.exists(SOURCESLISTFILE) or os.path.getsize(SOURCESLISTFILE) == 0:
36 print("Missing/empty sources.list file: %s" % SOURCESLISTFILE)
37 sys.exit(1)
38
39 if not os.path.exists(TRUSTEDFILE) or os.path.getsize(TRUSTEDFILE) == 0:
40 print("Missing/empty trusted.gpg file: %s" % TRUSTEDFILE)
41 sys.exit(1)
42
43 with tempfile.TemporaryDirectory() as homedir:
44 cmd = f'gpg -q --homedir {homedir} --no-default-keyring --keyring {TRUSTEDFILE} --fingerprint'
45 subprocess.check_call(cmd.split(), env=env)
46
47 cmd = f'add-apt-repository {remove} {yes} {noupdate} {param} {ppa}'
48 subprocess.check_call(cmd.split(), env=env)
49
50 if os.path.exists(SOURCESLISTFILE):
51 print("sources.list file not removed: %s" % SOURCESLISTFILE)
52 with open(SOURCESLISTFILE) as f:
53 print(f.read())
54 sys.exit(1)
55
56 if not os.path.exists(TRUSTEDFILE):
57 print("trusted.gpg should not have been removed, but it was: %s" % TRUSTEDFILE)
58 sys.exit(1)
59
60
61for PPAFORMAT in [f'{PPANAME}', f'{PPANAME}'.replace('/', '/ubuntu/')]:
62 for PPA in [f'{PPAFORMAT}', f'ppa:{PPAFORMAT}']:
63 for PARAM in ['-p', '--ppa', '']:
64 for YES in ['-y', '--yes']:
65 for NOUPDATE in ['-n', '--no-update', '']:
66 for REMOVE in ['-r', '--remove']:
67 for LOCALE in ['', 'C', 'C.UTF-8']:
68 run_test(PPA, PARAM, YES, NOUPDATE, REMOVE, LOCALE)
69
70sys.exit(0)
71
diff --git a/debian/tests/control b/debian/tests/control
index c90e3ef..ad74b04 100644
--- a/debian/tests/control
+++ b/debian/tests/control
@@ -11,6 +11,14 @@ Depends: dbus-x11,
11 xvfb,11 xvfb,
12 @12 @
1313
14Tests: add-apt-repository14Tests: add-apt-repository-ppa
15Depends: gpg, software-properties-common15Depends: gpg, software-properties-common
16Restrictions: needs-root, breaks-testbed16Restrictions: needs-root, breaks-testbed, allow-stderr
17
18Tests: add-apt-repository-cloud
19Depends: software-properties-common
20Restrictions: needs-root, breaks-testbed, allow-stderr, skippable
21
22Tests: add-apt-repository-archive
23Depends: software-properties-common
24Restrictions: needs-root, breaks-testbed, allow-stderr
diff --git a/softwareproperties/SoftwareProperties.py b/softwareproperties/SoftwareProperties.py
index fae6d18..30c0289 100644
--- a/softwareproperties/SoftwareProperties.py
+++ b/softwareproperties/SoftwareProperties.py
@@ -60,19 +60,12 @@ import aptsources.distro
60import softwareproperties60import softwareproperties
6161
62from .AptAuth import AptAuth62from .AptAuth import AptAuth
63from aptsources.sourceslist import SourcesList, SourceEntry63from aptsources.sourceslist import (SourcesList, SourceEntry)
64from . import shortcuts64from softwareproperties.shortcuthandler import (ShortcutException, InvalidShortcutException)
65from . import ppa65from softwareproperties.shortcuts import shortcut_handler
66from . import cloudarchive
6766
68from gi.repository import Gio67from gi.repository import Gio
6968
70_SHORTCUT_FACTORIES = [
71 ppa.shortcut_handler,
72 cloudarchive.shortcut_handler,
73 shortcuts.shortcut_handler,
74]
75
7669
77class SoftwareProperties(object):70class SoftwareProperties(object):
7871
@@ -678,31 +671,25 @@ class SoftwareProperties(object):
678 return os.path.splitext(os.path.basename(f))[0]671 return os.path.splitext(os.path.basename(f))[0]
679 return None672 return None
680673
681 def check_and_add_key_for_whitelisted_channels(self, srcline):
682 # This is maintained for any legacy callers
683 return self.check_and_add_key_for_whitelisted_shortcut(shortcut_handler(srcline))
684
685 def check_and_add_key_for_whitelisted_shortcut(self, shortcut):674 def check_and_add_key_for_whitelisted_shortcut(self, shortcut):
686 """675 """
687 helper that adds the gpg key of the channel to the apt676 helper that adds the gpg key of the channel to the apt
688 keyring *if* the channel is in the whitelist677 keyring *if* the channel is in the whitelist
689 /usr/share/app-install/channels or it is a public Launchpad PPA.678 /usr/share/app-install/channels or it is a public Launchpad PPA.
690 """679 """
691 (srcline, _fname) = shortcut.expand(680 srcline = shortcut.SourceEntry().line
692 codename=self.distro.codename, distro=self.distro.id.lower())
693 channel = self._is_line_in_whitelisted_channel(srcline)681 channel = self._is_line_in_whitelisted_channel(srcline)
694 if channel:682 if channel:
695 keyp = "%s/%s.key" % (self.CHANNEL_PATH, channel)683 keyp = "%s/%s.key" % (self.CHANNEL_PATH, channel)
696 self.add_key(keyp)684 self.add_key(keyp)
697685
698 cdata = (shortcut.add_key, {'keyserver': (self.options)})686 cdata = (shortcut.add_key, {})
699 def addkey_func():687 def addkey_func():
700 func, kwargs = cdata688 func, kwargs = cdata
701 msg = "Added key."689 msg = "Added key."
702 try:690 try:
703 ret = func(**kwargs)691 func(**kwargs)
704 if not ret:692 ret = True
705 msg = "Failed to add key."
706 except Exception as e:693 except Exception as e:
707 ret = False694 ret = False
708 msg = str(e)695 msg = str(e)
@@ -736,9 +723,12 @@ class SoftwareProperties(object):
736 """723 """
737 Add a source for the given line.724 Add a source for the given line.
738 """725 """
739 return self.add_source_from_shortcut(726 try:
740 shortcut=shortcut_handler(line.strip()),727 shortcut = shortcut_handler(line.strip())
741 enable_source_code=enable_source_code)728 except InvalidShortcutException:
729 return False
730
731 return self.add_source_from_shortcut(shortcut, enable_source_code)
742732
743 def add_source_from_shortcut(self, shortcut, enable_source_code=False):733 def add_source_from_shortcut(self, shortcut, enable_source_code=False):
744 """734 """
@@ -746,8 +736,8 @@ class SoftwareProperties(object):
746 site is in whitelist or the shortcut implementer adds it.736 site is in whitelist or the shortcut implementer adds it.
747 """737 """
748738
749 (deb_line, file) = shortcut.expand(739 deb_line = shortcut.SourceEntry().line
750 codename=self.distro.codename, distro=self.distro.id.lower())740 file = shortcut.sourceparts_file
751 deb_line = self.expand_http_line(deb_line)741 deb_line = self.expand_http_line(deb_line)
752 debsrc_entry_type = 'deb-src' if enable_source_code else '# deb-src'742 debsrc_entry_type = 'deb-src' if enable_source_code else '# deb-src'
753 debsrc_line = debsrc_entry_type + deb_line[3:]743 debsrc_line = debsrc_entry_type + deb_line[3:]
@@ -776,10 +766,10 @@ class SoftwareProperties(object):
776 worker.join(30)766 worker.join(30)
777 if worker.isAlive():767 if worker.isAlive():
778 # thread timed out.768 # thread timed out.
779 raise shortcuts.ShortcutException("Error: retrieving gpg key timed out.")769 raise ShortcutException("Error: retrieving gpg key timed out.")
780 result, msg = self.myqueue.get()770 result, msg = self.myqueue.get()
781 if not result:771 if not result:
782 raise shortcuts.ShortcutException(msg)772 raise ShortcutException(msg)
783773
784 if self.options and self.options.update:774 if self.options and self.options.update:
785 import apt775 import apt
@@ -866,14 +856,6 @@ class SoftwareProperties(object):
866 return "%s;%s;%s;" % (ver.package.shortname, ver.version,856 return "%s;%s;%s;" % (ver.package.shortname, ver.version,
867 ver.package.architecture())857 ver.package.architecture())
868858
869def shortcut_handler(shortcut):
870 for factory in _SHORTCUT_FACTORIES:
871 ret = factory(shortcut)
872 if ret is not None:
873 return ret
874
875 raise shortcuts.ShortcutException("Unable to handle input '%s'" % shortcut)
876
877859
878if __name__ == "__main__":860if __name__ == "__main__":
879 sp = SoftwareProperties()861 sp = SoftwareProperties()
diff --git a/softwareproperties/cloudarchive.py b/softwareproperties/cloudarchive.py
index 476452d..b59ace0 100644
--- a/softwareproperties/cloudarchive.py
+++ b/softwareproperties/cloudarchive.py
@@ -21,12 +21,17 @@
2121
22from __future__ import print_function22from __future__ import print_function
2323
24import apt_pkg
25import os24import os
26import subprocess25
27from gettext import gettext as _26from gettext import gettext as _
2827
29from softwareproperties.shortcuts import ShortcutException28from softwareproperties.shortcuthandler import (ShortcutHandler, ShortcutException,
29 InvalidShortcutException)
30from softwareproperties.sourceslist import SourcesListShortcutHandler
31from softwareproperties.uri import URIShortcutHandler
32
33from urllib.parse import urlparse
34
3035
31RELEASE_MAP = {36RELEASE_MAP = {
32 'folsom': 'precise',37 'folsom': 'precise',
@@ -46,98 +51,109 @@ RELEASE_MAP = {
46 'train': 'bionic',51 'train': 'bionic',
47 'ussuri': 'bionic',52 'ussuri': 'bionic',
48}53}
49MIRROR = "http://ubuntu-cloud.archive.canonical.com/ubuntu"
50UCA = "Ubuntu Cloud Archive"54UCA = "Ubuntu Cloud Archive"
51WEB_LINK = 'https://wiki.ubuntu.com/OpenStack/CloudArchive'55WEB_LINK = 'https://wiki.ubuntu.com/OpenStack/CloudArchive'
52APT_INSTALL_KEY = ['apt-get', '--quiet', '--assume-yes', 'install',
53 'ubuntu-cloud-keyring']
54
55ALIASES = {'tools-updates': 'tools'}
56for _r in RELEASE_MAP:
57 ALIASES["%s-updates" % _r] = _r
58
59MAP = {
60 'tools': {
61 'sldfmt': '%(codename)s-updates/cloud-tools',
62 'description': UCA + " for cloud-tools (JuJu and MAAS)"},
63 'tools-proposed': {
64 'sldfmt': '%(codename)s-proposed/cloud-tools',
65 'description': UCA + " for cloud-tools (JuJu and MAAS) [proposed]"}
66}
67
68for _r in RELEASE_MAP:
69 MAP[_r] = {
70 'sldfmt': '%(codename)s-updates/' + _r,
71 'description': UCA + ' for ' + 'OpenStack ' + _r.capitalize(),
72 'release': RELEASE_MAP[_r]}
73 MAP[_r + "-proposed"] = {
74 'sldfmt': '%(codename)s-proposed/' + _r,
75 'description': UCA + ' for ' + 'OpenStack %s [proposed]' % _r.capitalize(),
76 'release': RELEASE_MAP[_r]}
77
7856
79class CloudArchiveShortcutHandler(object):57UCA_ARCHIVE = "http://ubuntu-cloud.archive.canonical.com/ubuntu"
80 def __init__(self, shortcut):58UCA_PREFIXES = ['cloud-archive', 'uca']
81 self.shortcut = shortcut59UCA_VALID_POCKETS = ['updates', 'proposed']
8260UCA_DEFAULT_POCKET = UCA_VALID_POCKETS[0]
83 prefix = "cloud-archive:"61
8462
85 subs = {'shortcut': shortcut, 'prefix': prefix,63class CloudArchiveShortcutHandler(ShortcutHandler):
86 'ca_names': sorted(MAP.keys())}64 def __init__(self, shortcut, **kwargs):
87 if not shortcut.startswith(prefix):65 super(CloudArchiveShortcutHandler, self).__init__(shortcut, **kwargs)
88 raise ValueError(66 self.caname = None
89 _("shortcut '%(shortcut)s' did not start with '%(prefix)s'")67 self.pocket = None
90 % subs)68
9169 # one of these will set caname and pocket, and maybe _source_entry
92 name_in = shortcut[len(prefix):]70 if not any((self._match_uca(shortcut),
93 caname = ALIASES.get(name_in, name_in)71 self._match_uri(shortcut),
72 self._match_sourceslist(shortcut))):
73 msg = (_("not a valid cloud-archive format: '%s'") % shortcut)
74 raise InvalidShortcutException(msg)
75
76 self.caname = self.caname.lower()
77
78 self._filebase = "cloudarchive-%s" % self.caname
79
80 self.pocket = self.pocket.lower()
81 if not self.pocket in UCA_VALID_POCKETS:
82 msg = (_("not a valid cloud-archive pocket: '%s'") % self.pocket)
83 raise ShortcutException(msg)
84
85 if not self.caname in RELEASE_MAP:
86 msg = (_("not a valid cloud-archive: '%s'") % self.caname)
87 raise ShortcutException(msg)
88
89 codename = RELEASE_MAP[self.caname]
90 validnames = (self.codename, os.getenv("CA_ALLOW_CODENAME"))
91 if codename not in validnames:
92 msg = (_("cloud-archive for %s only supported on %s") %
93 (self.caname.capitalize(), codename.capitalize()))
94 raise ShortcutException(msg)
95
96 self._description = f'{UCA} for OpenStack {self.caname.capitalize()}'
97 if self.pocket == 'proposed':
98 self._description += ' [proposed]'
99
100 if not self._source_entry:
101 dist = ('%s-%s/%s' % (codename, self.pocket, self.caname))
102 comps = ' '.join(self.components) or 'main'
103 line = ' '.join([self.distro.binary_type, UCA_ARCHIVE, dist, comps])
104 self._set_source_entry(line)
105
106 @property
107 def description(self):
108 return self._description
109
110 @property
111 def web_link(self):
112 return WEB_LINK
113
114 def _encode_filebase(self, suffix=None):
115 # ignore suffix
116 return super(CloudArchiveShortcutHandler, self)._encode_filebase()
117
118 def _match_uca(self, shortcut):
119 (prefix, _, uca) = shortcut.rpartition(':')
120 if not prefix.lower() in UCA_PREFIXES:
121 return False
94122
95 subs.update({'input_name': name_in})123 (caname, _, pocket) = uca.partition('-')
96 if caname not in MAP:124 if not caname:
97 raise ShortcutException(125 return False
98 _("'%(input_name)s': not a valid cloud-archive name.\n"
99 "Must be one of %(ca_names)s") % subs)
100126
101 self.caname = caname127 self.caname = caname
102 self._info = MAP[caname].copy()128 self.pocket = pocket or UCA_DEFAULT_POCKET
103 self._info['web_link' ] = WEB_LINK
104
105 def info(self):
106 return self._info
107
108 def expand(self, codename, distro=None):
109 if codename not in (MAP[self.caname]['release'],
110 os.environ.get("CA_ALLOW_CODENAME")):
111 raise ShortcutException(
112 _("cloud-archive for %(os_release)s only supported on %(codename)s")
113 % {'codename': MAP[self.caname]['release'],
114 'os_release': self.caname.capitalize()})
115 dist = MAP[self.caname]['sldfmt'] % {'codename': codename}
116 line = ' '.join(('deb', MIRROR, dist, 'main',))
117 return (line, _fname_for_caname(self.caname))
118
119 def should_confirm(self):
120 return True129 return True
121130
122 def add_key(self, keyserver=None):131 def _match_uri(self, shortcut):
123 env = os.environ.copy()
124 env['DEBIAN_FRONTEND'] = 'noninteractive'
125 try:132 try:
126 subprocess.check_call(args=APT_INSTALL_KEY, env=env)133 return self._match_handler(URIShortcutHandler(shortcut))
127 except subprocess.CalledProcessError:134 except InvalidShortcutException:
135 return False
136
137 def _match_sourceslist(self, shortcut):
138 try:
139 return self._match_handler(SourcesListShortcutHandler(shortcut))
140 except InvalidShortcutException:
141 return False
142
143 def _match_handler(self, handler):
144 parsed = urlparse(handler.SourceEntry().uri)
145 if parsed.hostname != urlparse(UCA_ARCHIVE).hostname:
128 return False146 return False
129 return True
130147
148 (codename, _, caname) = handler.SourceEntry().dist.partition('/')
149 (codename, _, pocket) = codename.partition('-')
131150
132def _fname_for_caname(caname):151 if not all((codename, caname)):
133 # caname is an entry in MAP ('tools' or 'tools-proposed')152 return False
134 return os.path.join(
135 apt_pkg.config.find_dir("Dir::Etc::sourceparts"),
136 'cloudarchive-%s.list' % caname)
137153
154 self.caname = caname
155 self.pocket = pocket or UCA_DEFAULT_POCKET
156
157 self._set_source_entry(handler.SourceEntry().line)
158 return True
138159
139def shortcut_handler(shortcut):
140 try:
141 return CloudArchiveShortcutHandler(shortcut)
142 except ValueError:
143 return None
diff --git a/softwareproperties/gtk/DialogCacheOutdated.py b/softwareproperties/gtk/DialogCacheOutdated.py
index 5852897..9ee671e 100644
--- a/softwareproperties/gtk/DialogCacheOutdated.py
+++ b/softwareproperties/gtk/DialogCacheOutdated.py
@@ -81,9 +81,8 @@ class DialogCacheOutdated:
81 self._pdia.progressbar.set_fraction(perc / 100.0)81 self._pdia.progressbar.set_fraction(perc / 100.0)
8282
83 def on_pktask_finish(self, source, result, udata=(None,)):83 def on_pktask_finish(self, source, result, udata=(None,)):
84 results = None
85 try:84 try:
86 results = self._pktask.generic_finish(result)85 self._pktask.generic_finish(result)
87 except Exception as e:86 except Exception as e:
88 dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR,87 dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR,
89 Gtk.ButtonsType.CANCEL, _("Error while refreshing cache"))88 Gtk.ButtonsType.CANCEL, _("Error while refreshing cache"))
diff --git a/softwareproperties/gtk/SoftwarePropertiesGtk.py b/softwareproperties/gtk/SoftwarePropertiesGtk.py
index ae35ec0..a3021f6 100644
--- a/softwareproperties/gtk/SoftwarePropertiesGtk.py
+++ b/softwareproperties/gtk/SoftwarePropertiesGtk.py
@@ -1072,9 +1072,8 @@ class SoftwarePropertiesGtk(SoftwareProperties, SimpleGtkbuilderApp):
1072 self.progress_bar.set_fraction(prog_value / 100.0)1072 self.progress_bar.set_fraction(prog_value / 100.0)
10731073
1074 def on_driver_changes_finish(self, source, result, installs_pending):1074 def on_driver_changes_finish(self, source, result, installs_pending):
1075 results = None
1076 try:1075 try:
1077 results = self.pk_task.generic_finish(result)1076 self.pk_task.generic_finish(result)
1078 except Exception as e:1077 except Exception as e:
1079 self.on_driver_changes_revert()1078 self.on_driver_changes_revert()
1080 error(self.window_main, _("Error while applying changes"), str(e))1079 error(self.window_main, _("Error while applying changes"), str(e))
diff --git a/softwareproperties/kde/.keep b/softwareproperties/kde/.keep
1081deleted file mode 1006441080deleted file mode 100644
index e69de29..0000000
--- a/softwareproperties/kde/.keep
+++ /dev/null
diff --git a/softwareproperties/ppa.py b/softwareproperties/ppa.py
index 9ee8df8..351a609 100644
--- a/softwareproperties/ppa.py
+++ b/softwareproperties/ppa.py
@@ -1,8 +1,9 @@
1# software-properties PPA support1# software-properties PPA support, using launchpadlib
2#2#
3# Copyright (c) 2004-2009 Canonical Ltd.3# Copyright (c) 2019 Canonical Ltd.
4#4#
5# Author: Michael Vogt <mvo@debian.org>5# Original Author: Michael Vogt <mvo@debian.org>
6# Rewrite: Dan Streetman <ddstreet@canonical.com>
6#7#
7# This program is free software; you can redistribute it and/or8# This program is free software; you can redistribute it and/or
8# modify it under the terms of the GNU General Public License as9# modify it under the terms of the GNU General Public License as
@@ -19,457 +20,176 @@
19# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-130720# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
20# USA21# USA
2122
22from __future__ import print_function
23
24import apt_pkg
25import json
26import os
27import re
28import shutil
29import subprocess
30import tempfile
31import time
32
33from gettext import gettext as _23from gettext import gettext as _
34from threading import Thread
35
36from softwareproperties.shortcuts import ShortcutException
37
38try:
39 import urllib.request
40 from urllib.error import HTTPError, URLError
41 import urllib.parse
42 from http.client import HTTPException
43 NEED_PYCURL = False
44except ImportError:
45 NEED_PYCURL = True
46 import pycurl
47 HTTPError = pycurl.error
48
49
50SKS_KEYSERVER = 'https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&exact=on&search=0x%s'
51# maintained until 2015
52LAUNCHPAD_PPA_API = 'https://launchpad.net/api/devel/%s/+archive/%s'
53LAUNCHPAD_USER_API = 'https://launchpad.net/api/1.0/%s'
54LAUNCHPAD_USER_PPAS_API = 'https://launchpad.net/api/1.0/%s/ppas'
55LAUNCHPAD_DISTRIBUTION_API = 'https://launchpad.net/api/1.0/%s'
56LAUNCHPAD_DISTRIBUTION_SERIES_API = 'https://launchpad.net/api/1.0/%s/%s'
57# Specify to use the system default SSL store; change to a different path
58# to test with custom certificates.
59LAUNCHPAD_PPA_CERT = "/etc/ssl/certs/ca-certificates.crt"
60
61
62class CurlCallback:
63 def __init__(self):
64 self.contents = ''
65
66 def body_callback(self, buf):
67 self.contents = self.contents + buf
68
69
70class PPAException(Exception):
71
72 def __init__(self, value, original_error=None):
73 self.value = value
74 self.original_error = original_error
75
76 def __str__(self):
77 return repr(self.value)
78
79
80def encode(s):
81 return re.sub("[^a-zA-Z0-9_-]", "_", s)
82
83
84def get_info_from_https(url, accept_json, retry_delays=None):
85 """Return the content from url.
86 accept_json indicates that:
87 a.) Send header Accept: 'application/json'
88 b.) Instead of raw content, return json.loads(content)
89 retry_delays is None or an iterator (including list or tuple)
90 If it is None, no retries will be done.
91 If it is an iterator, each value is number of seconds to delay before
92 retrying. For example, retry_delays=(3,5) means to try up to 3
93 times, with a 3s delay after first failure and 5s delay after second.
94 Retries will not be done on 404."""
95 func = _get_https_content_pycurl if NEED_PYCURL else _get_https_content_py3
96 data = func(lp_url=url, accept_json=accept_json, retry_delays=retry_delays)
97 if accept_json:
98 return json.loads(data)
99 else:
100 return data
101
102
103def get_info_from_lp(lp_url):
104 return get_info_from_https(lp_url, True)
105
106def get_ppa_info_from_lp(owner_name, ppa):
107 if owner_name[0] != '~':
108 owner_name = '~' + owner_name
109 lp_url = LAUNCHPAD_PPA_API % (owner_name, ppa)
110 return get_info_from_lp(lp_url)
111
112def series_valid_for_distro(distribution, series):
113 lp_url = LAUNCHPAD_DISTRIBUTION_SERIES_API % (distribution, series)
114 try:
115 get_info_from_lp(lp_url)
116 return True
117 except PPAException:
118 return False
11924
120def get_current_series_from_lp(distribution):25from launchpadlib.launchpad import Launchpad
121 lp_url = LAUNCHPAD_DISTRIBUTION_API % distribution26from lazr.restfulclient.errors import (NotFound, BadRequest, Unauthorized)
122 return os.path.basename(get_info_from_lp(lp_url)["current_series_link"])
12327
28from softwareproperties.shortcuthandler import (ShortcutHandler, ShortcutException,
29 InvalidShortcutException)
30from softwareproperties.sourceslist import SourcesListShortcutHandler
31from softwareproperties.uri import URIShortcutHandler
12432
125def _get_https_content_py3(lp_url, accept_json, retry_delays=None):33from urllib.parse import urlparse
126 if retry_delays is None:
127 retry_delays = []
12834
129 trynum = 0
130 err = None
131 sleep_waits = iter(retry_delays)
132 headers = {"Accept": "application/json"} if accept_json else {}
13335
134 while True:36PPA_URI_FORMAT = 'http://ppa.launchpad.net/{team}/{ppa}/ubuntu/'
135 trynum += 137PRIVATE_PPA_URI_FORMAT = 'https://private-ppa.launchpad.net/{team}/{ppa}/ubuntu/'
136 try:38PPA_VALID_HOSTNAMES = [urlparse(PPA_URI_FORMAT).hostname, urlparse(PRIVATE_PPA_URI_FORMAT).hostname]
137 request = urllib.request.Request(str(lp_url), headers=headers)
138 lp_page = urllib.request.urlopen(request,
139 cafile=LAUNCHPAD_PPA_CERT)
140 return lp_page.read().decode("utf-8", "strict")
141 except (HTTPException, URLError) as e:
142 err = PPAException(
143 "Error reading %s (%d tries): %s" % (lp_url, trynum, e.reason),
144 e)
145 # do not retry on 404. HTTPError is a subclass of URLError.
146 if isinstance(e, HTTPError) and e.code == 404:
147 break
148 try:
149 time.sleep(next(sleep_waits))
150 except StopIteration:
151 break
15239
153 raise err
15440
41class PPAShortcutHandler(ShortcutHandler):
42 def __init__(self, shortcut, login=False, **kwargs):
43 super(PPAShortcutHandler, self).__init__(shortcut, **kwargs)
44 self._lp_anon = not login
45 self._signing_key_data = None
15546
156def _get_https_content_pycurl(lp_url, accept_json, retry_delays=None):47 self._lp = None # LP object
157 # this is the fallback code for python248 self._lpteam = None # Person/Team LP object
158 if retry_delays is None:49 self._lpppa = None # PPA Archive LP object
159 retry_delays = []
16050
161 trynum = 051 # one of these will set teamname and ppaname, and maybe _source_entry
162 sleep_waits = iter(retry_delays)52 if not any((self._match_ppa(shortcut),
53 self._match_uri(shortcut),
54 self._match_sourceslist(shortcut))):
55 msg = (_("ERROR: '%s' is not a valid ppa format") % shortcut)
56 raise InvalidShortcutException(msg)
16357
164 while True:58 self._filebase = "%s-ubuntu-%s" % (self.teamname, self.ppaname)
165 err_msg = None59 self._set_auth()
166 err = None
167 trynum += 1
168 try:
169 callback = CurlCallback()
170 curl = pycurl.Curl()
171 curl.setopt(pycurl.SSL_VERIFYPEER, 1)
172 curl.setopt(pycurl.SSL_VERIFYHOST, 2)
173 curl.setopt(pycurl.FOLLOWLOCATION, 1)
174 curl.setopt(pycurl.WRITEFUNCTION, callback.body_callback)
175 if LAUNCHPAD_PPA_CERT:
176 curl.setopt(pycurl.CAINFO, LAUNCHPAD_PPA_CERT)
177 curl.setopt(pycurl.URL, str(lp_url))
178 if accept_json:
179 curl.setopt(pycurl.HTTPHEADER, ["Accept: application/json"])
180 curl.perform()
181 response = curl.getinfo(curl.RESPONSE_CODE)
182 curl.close()
183
184 if response != 200:
185 err_msg = "response code %i" % response
186 except pycurl.error as e:
187 err_msg = str(e)
188 err = e
189
190 if err_msg is None:
191 return callback.contents
19260
193 try:61 if not self._source_entry:
194 time.sleep(next(sleep_waits))62 uri_format = PRIVATE_PPA_URI_FORMAT if self.lpppa.private else PPA_URI_FORMAT
195 except StopIteration:63 uri = uri_format.format(team=self.teamname, ppa=self.ppaname)
196 break64 line = ('%s %s %s %s' % (self.binary_type, uri, self.codename, ' '.join(self.components) or 'main'))
19765 self._set_source_entry(line)
198 raise PPAException(
199 "Error reading %s (%d tries): %s" % (lp_url, trynum, err_msg),
200 original_error=err)
201
202
203def mangle_ppa_shortcut(shortcut):
204 if ":" in shortcut:
205 ppa_shortcut = shortcut.split(":")[1]
206 else:
207 ppa_shortcut = shortcut
208 if ppa_shortcut.startswith("/"):
209 ppa_shortcut = ppa_shortcut.lstrip("/")
210 user = ppa_shortcut.split("/")[0]
211 if (user[0] == "~"):
212 user = user[1:]
213 ppa_path_objs = ppa_shortcut.split("/")[1:]
214 ppa_path = []
215 if (len(ppa_path_objs) < 1):
216 ppa_path = ['ubuntu', 'ppa']
217 elif (len(ppa_path_objs) == 1):
218 ppa_path.insert(0, "ubuntu")
219 ppa_path.extend(ppa_path_objs)
220 else:
221 ppa_path = ppa_path_objs
222 ppa = "~%s/%s" % (user, "/".join(ppa_path))
223 return ppa
224
225def verify_keyid_is_v4(signing_key_fingerprint):
226 """Verify that the keyid is a v4 fingerprint with at least 160bit"""
227 return len(signing_key_fingerprint) >= 160/8
228
229
230class AddPPASigningKey(object):
231 " thread class for adding the signing key in the background "
232
233 def __init__(self, ppa_path, keyserver=None):
234 self.ppa_path = ppa_path
235 self._homedir = tempfile.mkdtemp()
236
237 def __del__(self):
238 shutil.rmtree(self._homedir)
239
240 def gpg_cmd(self, args):
241 cmd = "gpg -q --homedir %s --no-default-keyring --no-options --import --import-options %s" % (self._homedir, args)
242 return subprocess.Popen(cmd.split(), stdin=subprocess.PIPE, stdout=subprocess.PIPE)
243
244 def _recv_key(self, ppa_info):
245 signing_key_fingerprint = ppa_info["signing_key_fingerprint"]
246 try:
247 # double check that the signing key is a v4 fingerprint (160bit)
248 if not verify_keyid_is_v4(signing_key_fingerprint):
249 print("Error: signing key fingerprint '%s' too short" %
250 signing_key_fingerprint)
251 return False
252 except TypeError:
253 print("Error: signing key fingerprint does not exist")
254 return False
255
256 return get_ppa_signing_key_data(ppa_info)
25766
258 def _minimize_key(self, key):67 @property
259 p = self.gpg_cmd("import-minimal,import-export")68 def lp(self):
260 (minimal_key, _) = p.communicate(key.encode())69 if not self._lp:
261 70 if self._lp_anon:
262 if p.returncode != 0:71 login_func = Launchpad.login_anonymously
263 return False72 else:
264 return minimal_key73 login_func = Launchpad.login_with
26574 self._lp = login_func("%s.%s" % (self.__module__, self.__class__.__name__),
266 def _get_fingerprints(self, key):75 service_root='production',
267 fingerprints = []76 version='devel')
268 p = self.gpg_cmd("show-only --fingerprint --batch --with-colons")77 return self._lp
269 (output, _) = p.communicate(key)78
270 if p.returncode == 0:79 @property
271 for line in output.decode('utf-8').splitlines():80 def lpteam(self):
272 if line.startswith("fpr:"):81 if not self._lpteam:
273 fingerprints.append(line.split(":")[9])82 try:
274 return fingerprints83 self._lpteam = self.lp.people(self.teamname)
27584 except NotFound:
276 def _verify_fingerprint(self, key, expected_fingerprint):85 msg = (_("ERROR: user/team '%s' not found (use --login if private)") % self.teamname)
277 got_fingerprints = self._get_fingerprints(key)86 raise ShortcutException(msg)
278 if len(got_fingerprints) != 1:87 except Unauthorized:
279 print("Got '%s' fingerprints, expected only one" %88 msg = (_("ERROR: invalid user/team name '%s'") % self.teamname)
280 len(got_fingerprints))89 raise ShortcutException(msg)
281 return False90 return self._lpteam
282 got_fingerprint = got_fingerprints[0]91
283 if got_fingerprint != expected_fingerprint:92 @property
284 print("Fingerprints do not match, not importing: '%s' != '%s'" % (93 def lpppa(self):
285 expected_fingerprint, got_fingerprint))94 if not self._lpppa:
95 try:
96 self._lpppa = self.lpteam.getPPAByName(name=self.ppaname)
97 except NotFound:
98 msg = (_("ERROR: ppa '%s/%s' not found (use --login if private)") %
99 (self.teamname, self.ppaname))
100 raise ShortcutException(msg)
101 except BadRequest:
102 msg = (_("ERROR: invalid ppa name '%s'") % self.ppaname)
103 raise ShortcutException(msg)
104 return self._lpppa
105
106 @property
107 def description(self):
108 return self.lpppa.description
109
110 @property
111 def web_link(self):
112 return self.lpppa.web_link
113
114 @property
115 def trustedparts_content(self):
116 if not self._signing_key_data:
117 key = self.lpppa.getSigningKeyData()
118 fingerprint = self.lpppa.signing_key_fingerprint
119
120 if not fingerprint:
121 msg = _("Warning: could not get PPA signing_key_fingerprint from LP, using anyway")
122 elif not fingerprint in self.fingerprints(key):
123 msg = (_("Fingerprints do not match, not importing: '%s' != '%s'") %
124 (fingerprint, ','.join(self.fingerprints(key))))
125 raise ShortcutException(msg)
126
127 self._signing_key_data = key
128 return self._signing_key_data
129
130 def _match_ppa(self, shortcut):
131 (prefix, _, ppa) = shortcut.rpartition(':')
132 if not prefix.lower() == 'ppa':
286 return False133 return False
287 return True
288134
289 def add_ppa_signing_key(self, ppa_path=None):135 (teamname, _, ppaname) = ppa.partition('/')
290 """Query and add the corresponding PPA signing key.136 teamname = teamname.lstrip('~')
137 if '/' in ppaname:
138 (ubuntu, _, ppaname) = ppaname.partition('/')
139 if ubuntu.lower() != 'ubuntu':
140 # PPAs only support ubuntu
141 return False
142 if '/' in ppaname:
143 # Path is too long for valid ppa
144 return False
291145
292 The signing key fingerprint is obtained from the Launchpad PPA page,146 self.teamname = teamname
293 via a secure channel, so it can be trusted.147 self.ppaname = ppaname or 'ppa'
294 """148 return True
295 if ppa_path is None:
296 ppa_path = self.ppa_path
297149
150 def _match_uri(self, shortcut):
298 try:151 try:
299 ppa_info = get_ppa_info(mangle_ppa_shortcut(ppa_path))152 return self._match_handler(URIShortcutHandler(shortcut))
300 except PPAException as e:153 except InvalidShortcutException:
301 print(e.value)
302 return False154 return False
155
156 def _match_sourceslist(self, shortcut):
303 try:157 try:
304 signing_key_fingerprint = ppa_info["signing_key_fingerprint"]158 return self._match_handler(SourcesListShortcutHandler(shortcut))
305 except IndexError:159 except InvalidShortcutException:
306 print("Error: can't find signing_key_fingerprint at %s" % ppa_path)
307 return False160 return False
308
309 # download the armored_key
310 armored_key = self._recv_key(ppa_info)
311 if not armored_key:
312 return False
313
314 trustedgpgd = apt_pkg.config.find_dir("Dir::Etc::trustedparts")
315 apt_keyring = os.path.join(trustedgpgd, encode(ppa_info["reference"][1:]))
316161
317 minimal_key = self._minimize_key(armored_key)162 def _match_handler(self, handler):
318 if not minimal_key:163 parsed = urlparse(handler.SourceEntry().uri)
164 if not parsed.hostname in PPA_VALID_HOSTNAMES:
319 return False165 return False
320
321 if not self._verify_fingerprint(minimal_key, signing_key_fingerprint):
322 return False
323
324 with open('%s.gpg' % apt_keyring, 'wb') as f:
325 f.write(minimal_key)
326
327 return True
328
329166
330class AddPPASigningKeyThread(Thread, AddPPASigningKey):167 path = parsed.path.strip().strip('/').split('/')
331 # This class is legacy. There are no users inside the software-properties168 if len(path) < 2:
332 # codebase other than a test case. It was left in case there were outside169 return False
333 # users. Internally, we've changed from having a class implement the170 self.teamname = path[0]
334 # tread to explicitly launching a thread and invoking a method in it171 self.ppaname = path[1]
335 # see check_and_add_key_for_whitelisted_shortcut for how.
336 def __init__(self, ppa_path, keyserver=None):
337 Thread.__init__(self)
338 AddPPASigningKey.__init__(self, ppa_path=ppa_path, keyserver=keyserver)
339
340 def run(self):
341 self.add_ppa_signing_key(self.ppa_path)
342172
173 self._username = handler.username
174 self._password = handler.password
343175
344def _get_suggested_ppa_message(user, ppa_name):176 self._set_source_entry(handler.SourceEntry().line)
345 try:
346 msg = []
347 try:
348 try:
349 lp_user = get_info_from_lp(LAUNCHPAD_USER_API % user)
350 except PPAException:
351 return _("ERROR: '{user}' user or team does not exist.").format(user=user)
352 lp_ppas = get_info_from_lp(LAUNCHPAD_USER_PPAS_API % user)
353 entity_name = _("team") if lp_user["is_team"] else _("user")
354 if lp_ppas["total_size"] > 0:
355 # Translators: %(entity)s is either "team" or "user"
356 msg.append(_("The %(entity)s named '%(user)s' has no PPA named '%(ppa)s'") % {
357 'entity' : entity_name,
358 'user' : user,
359 'ppa' : ppa_name})
360 msg.append(_("Please choose from the following available PPAs:"))
361 for ppa in lp_ppas["entries"]:
362 msg.append(_(" * '%(name)s': %(displayname)s") % {
363 'name' : ppa["name"],
364 'displayname' : ppa["displayname"]})
365 else:
366 # Translators: %(entity)s is either "team" or "user"
367 msg.append(_("The %(entity)s named '%(user)s' does not have any PPA") % {
368 'entity' : entity_name, 'user' : user})
369 return '\n'.join(msg)
370 except KeyError:
371 return ''
372 except ImportError:
373 return _("Please check that the PPA name or format is correct.")
374
375
376def get_ppa_info(shortcut):
377 user = shortcut.split("/")[0]
378 ppa = "/".join(shortcut.split("/")[1:])
379 try:
380 ret = get_ppa_info_from_lp(user, ppa)
381 ret["distribution"] = ret["distribution_link"].split('/')[-1]
382 ret["owner"] = ret["owner_link"].split('/')[-1]
383 return ret
384 except (HTTPError, Exception):
385 msg = []
386 msg.append(_("Cannot add PPA: 'ppa:%s/%s'.") % (
387 user, ppa))
388
389 # If the PPA does not exist, then try to find if the user/team
390 # exists. If it exists, list down the PPAs
391 raise ShortcutException('\n'.join(msg) + "\n" +
392 _get_suggested_ppa_message(user, ppa))
393
394 except (ValueError, PPAException):
395 raise ShortcutException(
396 _("Cannot access PPA (%s) to get PPA information, "
397 "please check your internet connection.") % \
398 (LAUNCHPAD_PPA_API % (user, ppa)))
399
400
401def get_ppa_signing_key_data(info=None):
402 """Return signing key data in armored ascii format for the provided ppa.
403
404 If 'info' is a dictionary, it is assumed to be the result
405 of 'get_ppa_info(ppa)'. If it is a string, it is assumed to
406 be a ppa_path.
407
408 Return value is a text string."""
409 if isinstance(info, dict):
410 link = info["self_link"]
411 else:
412 link = get_ppa_info(mangle_ppa_shortcut(info))["self_link"]
413
414 return get_info_from_https(link + "?ws.op=getSigningKeyData",
415 accept_json=True, retry_delays=(1, 2, 3))
416
417
418class PPAShortcutHandler(object):
419 def __init__(self, shortcut):
420 super(PPAShortcutHandler, self).__init__()
421 try:
422 self.shortcut = mangle_ppa_shortcut(shortcut)
423 except:
424 raise ShortcutException(_("ERROR: '{shortcut}' is not a valid ppa format")
425 .format(shortcut=shortcut))
426 info = get_ppa_info(self.shortcut)
427
428 if "private" in info and info["private"]:
429 raise ShortcutException(
430 _("Adding private PPAs is not supported currently"))
431
432 self._info = info
433
434 def info(self):
435 return self._info
436
437 def expand(self, codename, distro=None):
438 if (distro is not None
439 and distro != self._info["distribution"]
440 and not series_valid_for_distro(self._info["distribution"], codename)):
441 # The requested PPA is for a foreign distribution. Guess that
442 # the user wants that distribution's current series.
443 # This only applies if the local distribution is not the same
444 # distribution the remote PPA is associated with AND the local
445 # codename is not equal to the PPA's series.
446 # e.g. local:Foobar/xenial and ppa:Ubuntu/xenial will use 'xenial'
447 # local:Foobar/fluffy and ppa:Ubuntu/xenial will use '$latest'
448 codename = get_current_series_from_lp(self._info["distribution"])
449 debline = "deb http://ppa.launchpad.net/%s/%s/%s %s main" % (
450 self._info["owner"][1:], self._info["name"],
451 self._info["distribution"], codename)
452 sourceslistd = apt_pkg.config.find_dir("Dir::Etc::sourceparts")
453 filename = os.path.join(sourceslistd, "%s-%s-%s-%s.list" % (
454 encode(self._info["owner"][1:]), encode(self._info["distribution"]),
455 encode(self._info["name"]), codename))
456 return (debline, filename)
457
458 def should_confirm(self):
459 return True177 return True
460178
461 def add_key(self, keyserver=None):179 def _set_auth(self):
462 apsk = AddPPASigningKey(self._info["reference"], keyserver=keyserver)180 if self._lp_anon or not self.lpppa.private:
463 return apsk.add_ppa_signing_key()181 return
464
465182
466def shortcut_handler(shortcut):183 if self._username and self._password:
467 if not shortcut.startswith("ppa:"):184 return
468 return None
469 return PPAShortcutHandler(shortcut)
470185
471186 for url in self.lp.me.getArchiveSubscriptionURLs():
472if __name__ == "__main__":187 parsed = urlparse(url)
473 import sys188 if parsed.path.startswith(f'/{self.teamname}/{self.ppaname}/ubuntu'):
474 ppa = mangle_ppa_shortcut(sys.argv[1])189 self._username = parsed.username
475 print(get_ppa_info(ppa))190 self._password = parsed.password
191 break
192 else:
193 msg = (_("Could not find PPA subscription for ppa:%s/%s, you may need to request access") %
194 (self.teamname, self.ppaname))
195 raise ShortcutException(msg)
diff --git a/softwareproperties/shortcuthandler.py b/softwareproperties/shortcuthandler.py
476new file mode 100644196new file mode 100644
index 0000000..6057e13
--- /dev/null
+++ b/softwareproperties/shortcuthandler.py
@@ -0,0 +1,617 @@
1# Copyright (c) 2019 Canonical Ltd.
2#
3# This program is free software; you can redistribute it and/or
4# modify it under the terms of the GNU General Public License as
5# published by the Free Software Foundation; either version 2 of the
6# License, or (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
16# USA
17
18import os
19import re
20import apt_pkg
21import subprocess
22import tempfile
23
24from aptsources.distro import get_distro
25from aptsources.sourceslist import (SourceEntry, SourcesList)
26
27from contextlib import suppress
28
29from copy import copy
30
31from gettext import gettext as _
32
33from urllib.parse import urlparse
34
35
36GPG_KEYRING_CMD = 'gpg -q --no-options --no-default-keyring --batch --keyring %s'
37
38class ShortcutHandler(object):
39 '''Superclass for shortcut handler implementations.
40
41 This provides a way to take a apt repository reference, in various forms,
42 and write the specific apt configuration to local files. This also can
43 remove previously written configuration from local files.
44
45 This class and any subclasses should never modify any main apt configuration
46 files, only specifically named files in '.d' subdirs (e.g. sources.list.d, etc)
47 should be modified. The only exception to that rule is adding or removing
48 sourceslist lines or components of existing source entries.
49 '''
50 def __init__(self, shortcut, components=None, enable_source=False, codename=None, dry_run=False, **kwargs):
51 self.shortcut = shortcut
52 self.components = components or []
53 self.enable_source = enable_source
54 self.distro = get_distro()
55 self.codename = codename or self.distro.codename
56 self.dry_run = dry_run
57
58 # Subclasses should not directly reference _source_entry,
59 # use _set_source_entry() and SourceEntry()
60 self._source_entry = None
61
62 # Subclasses should directly set these fields, if appropriate
63 self._filebase = None
64 self._username = None
65 self._password = None
66
67 @classmethod
68 def is_valid_uri(cls, uri):
69 '''Return if the uri is in valid uri format'''
70 parsed = urlparse(uri)
71 return parsed.scheme and parsed.netloc
72
73 @classmethod
74 def uri_strip_auth(cls, uri):
75 '''Return the uri with the username and password stripped'''
76 parsed = urlparse(uri)
77 # urlparse doesn't have any great way to simply remove the auth data,
78 # so let's just strip everything to the left of '@'
79 return parsed._replace(netloc=parsed.netloc.rpartition('@')[2]).geturl()
80
81 @classmethod
82 def uri_insert_auth(cls, uri, username, password):
83 '''Return the uri with the username and password included'''
84 parsed = urlparse(cls.uri_strip_auth(uri))
85 netloc='%s:%s@%s' % (username, password, parsed.netloc)
86 return parsed._replace(netloc=netloc).geturl()
87
88 @classmethod
89 def fingerprints(cls, keys):
90 '''Return an array of fingerprint(s) for provided key(s).
91
92 The 'keys' parameter should be in text (str) or binary (bytes) format;
93 it is converted to bytes if needed, and then passed to the 'gpg' program.
94 '''
95 cmd = 'gpg -q --no-options --no-keyring --batch --with-colons'
96 # yes, --with-fingerprint twice, to print subkey fingerprints
97 cmd += ' --with-fingerprint' * 2
98 try:
99 with tempfile.TemporaryDirectory() as homedir:
100 cmd += f' --homedir {homedir}'
101 if not isinstance(keys, bytes):
102 keys = keys.encode()
103 stdout = subprocess.run(cmd.split(), check=True, input=keys,
104 stdout=subprocess.PIPE).stdout.decode()
105 except subprocess.CalledProcessError as e:
106 print(_("Warning: gpg error while processing keys:\n%s") % e)
107 return []
108
109 try:
110 # gpg --with-colons fpr field puts fingerprint into (1-based) field 10
111 return [l.split(':')[9] for l in stdout.splitlines() if l.startswith('fpr')]
112 except KeyError:
113 print(_("Warning: invalid gpg output:\n%s") % stdout)
114 return []
115
116 @property
117 def description(self):
118 return (_("Archive for codename: %s components: %s" %
119 (self.SourceEntry().dist,
120 ','.join(self.SourceEntry().comps))))
121
122 @property
123 def web_link(self):
124 return self.archive_link
125
126 @property
127 def archive_link(self):
128 return self.SourceEntry().uri
129
130 @property
131 def binary_type(self):
132 '''Text indicating a binary-type SourceEntry.'''
133 return self.distro.binary_type
134
135 @property
136 def source_type(self):
137 '''Text indicating a source-type SourceEntry.'''
138 return self.distro.source_type
139
140 def SourceEntry(self, pkgtype=None):
141 '''Get the SourceEntry representing this archive/shortcut.
142
143 This should never include any authentication data; if required,
144 the username and password should only be available from the
145 username and password properties, as well as from the
146 netrcparts_content property.
147
148 If pkgtype is provided, it must be either binary_type or source_type,
149 in which case this returns a SourceEntry with the requested type.
150 If pkgtype is not specified, this returns a SourceEntry with an
151 implementation-dependent type (in most cases, implementations should
152 default to binary_type).
153
154 Note that the default SourceEntry will be returned without modification,
155 and the implementation will determine if it is enabled or disabled;
156 while the source-type SourceEntry will be enabled or disabled based on
157 self.enable_source. The binary-type SourceEntry will always be enabled.
158
159 The SourceEntry 'file' field should always be set to the value of
160 sourceparts_file.
161 '''
162 if not self._source_entry:
163 raise NotImplementedError('Implementation class did not set self._source_entry')
164 e = copy(self._source_entry)
165 if not pkgtype:
166 return e
167 if pkgtype == self.binary_type:
168 e.set_enabled(True)
169 e.type = self.binary_type
170 elif pkgtype == self.source_type:
171 e.set_enabled(self.enable_source)
172 e.type = self.source_type
173 else:
174 raise ValueError('Invalid pkgtype: %s' % pkgtype)
175 return SourceEntry(str(e), file=e.file)
176
177 @property
178 def username(self):
179 '''Return the username used for authentication
180
181 If authentication is used, return the username; otherwise return None.
182
183 By default, this returns the private variable self._username, which
184 defaults to None. Subclasses should override this method and/or
185 set self._username if they have authentication data.
186 '''
187 return self._username
188
189 @property
190 def password(self):
191 '''Return the password used for authentication
192
193 If authentication is used, return the password; otherwise return None.
194
195 By default, this returns the private variable self._password, which
196 defaults to None. Subclasses should override this method and/or
197 set self._password if they have authentication data.
198 '''
199 return self._password
200
201 def add(self):
202 '''Save all data for this shortcut to file(s).
203
204 This writes everything to the relevant files. By default, it
205 calls add_source(), add_key(), and add_login(). Subclasses
206 should override it if other actions are required.
207 '''
208 self.add_source()
209 self.add_key()
210 self.add_login()
211
212 def remove(self):
213 '''Remove all data for this shortcut from file(s).
214
215 This removes everything from the relevant files. By default, it
216 only calls remove_source() and remove_login(). Subclasses
217 should override it if other actions are required. Note that by
218 default is does not call remove_key().
219 '''
220 self.remove_source()
221 self.remove_login()
222
223 def add_source(self):
224 '''Add the apt SourceEntries.
225
226 This uses SourcesList to add the binary-type and source-type
227 SourceEntries.
228
229 If the SourceEntry matches a known apt template, this will ignore
230 the sourceparts_file and instead place the SourceEntries into
231 the main/default sources.list file. Otherwise, this will add
232 the SourceEntries into the sourceparts_file.
233
234 If either the binary-type or source-type entry exist in the current
235 SourcesList, the existing entries are updated instead of placing
236 the entries in the sourceparts_file.
237 '''
238 binentry = self.SourceEntry(self.binary_type)
239 srcentry = self.SourceEntry(self.source_type)
240 mode = self.sourceparts_mode
241
242 sourceslist = SourcesList()
243
244 count = len(sourceslist.list)
245 newentry = sourceslist.add_entry(binentry)
246 if count == len(sourceslist.list):
247 print(_("Found existing %s entry in %s") % (newentry.type, newentry.file))
248 if binentry.file != newentry.file:
249 # existing binentry, but not in file we were expecting, just update it
250 print(_("Updating existing entry instead of using %s") % binentry.file)
251 elif newentry.template:
252 # our SourceEntry matches a template; use default sources.list file
253 newentry.file = SourceEntry('').file
254 print(_("Archive has template, updating %s") % newentry.file)
255 elif binentry.disabled:
256 print(_("Adding disabled %s entry to %s") % (newentry.type, newentry.file))
257 else:
258 print(_("Adding %s entry to %s") % (newentry.type, newentry.file))
259 binpos = sourceslist.list.index(newentry)
260 newentry.set_enabled(not binentry.disabled)
261 binentry = newentry
262
263 # Unless it already exists somewhere, add the srcentry right after the binentry
264 srcentry.file = binentry.file
265 count = len(sourceslist.list)
266 newentry = sourceslist.add_entry(srcentry, pos=binpos)
267 if count == len(sourceslist.list):
268 print(_("Found existing %s entry in %s") % (newentry.type, newentry.file))
269 if srcentry.file != newentry.file:
270 # existing srcentry, but not in file we were expecting, just update it
271 print(_("Updating existing entry instead of using %s") % srcentry.file)
272 elif srcentry.disabled:
273 print(_("Adding disabled %s entry to %s") % (newentry.type, newentry.file))
274 else:
275 print(_("Adding %s entry to %s") % (newentry.type, newentry.file))
276 newentry.set_enabled(not srcentry.disabled)
277 srcentry = newentry
278
279 if not self.dry_run:
280 # If the file doesn't exist, create it so we can set the mode
281 for entryfile in set([binentry.file, srcentry.file]):
282 if not os.path.exists(entryfile):
283 with open(entryfile, 'w'):
284 os.chmod(entryfile, mode)
285 sourceslist.save()
286
287 def remove_source(self):
288 '''Remove the apt SourceEntries.
289
290 This uses SourcesList to remove the binary-type and source-type
291 SourceEntries.
292
293 This must disable the corresponding SourceEntries, from whatever file(s)
294 they are located in. This must not disable more than matches, e.g.
295 if the existing SourceEntry line contains more components this must
296 edit the existing line to remove this SourceEntry's component(s).
297
298 After disabling all matching SourceEntries, if the sourceparts_file is
299 empty or contains only invalid and/or disabled SourceEntries, this
300 may remove the sourceparts_file.
301 '''
302 sourceslist = SourcesList()
303
304 binentry = self.SourceEntry(self.binary_type)
305 srcentry = self.SourceEntry(self.source_type)
306 binentry.set_enabled(False)
307 srcentry.set_enabled(False)
308
309 # first, disable our entries
310 print(_("Disabling %s entry in %s") % (binentry.type, binentry.file))
311 sourceslist.add_entry(binentry)
312 print(_("Disabling %s entry in %s") % (srcentry.type, srcentry.file))
313 sourceslist.add_entry(srcentry)
314
315 file_entries = [s for s in sourceslist if s.file == self.sourceparts_file]
316 if not [e for e in file_entries if not e.invalid and not e.disabled]:
317 # no more valid/enabled entries in our file, remove them
318 for e in file_entries:
319 if not e.invalid:
320 print(_("Removing disabled %s entry from %s") % (e.type, e.file))
321 sourceslist.remove(e)
322
323 if not self.dry_run:
324 sourceslist.save(remove=True)
325
326 @property
327 def sourceparts_path(self):
328 '''Return result of apt_pkg.config.find_dir("Dir::Etc::sourceparts")'''
329 return apt_pkg.config.find_dir("Dir::Etc::sourceparts")
330
331 @property
332 def sourceparts_filename(self):
333 '''Get the sources.list.d filename, without the leading path.
334
335 By default, this combines the filebase with the codename, and uses a
336 extension of 'list'. This is different than the trustedparts or
337 netrcparts filenames, which use only the filebase plus extension.
338 '''
339 return self._filebase_to_filename('list', suffix=self.codename)
340
341 @property
342 def sourceparts_file(self):
343 '''Get the sources.list.d absolute-path filename.
344
345 Note that the add_source() function will not use this file if this shortcut's
346 SourceEntry matches a known apt template; instead the entries will be placed
347 in the main sources.list file. Also, if the SourceEntry already exists in
348 the SourcesList, it will be edited in place, instead of using this file.
349 See add_source() for more details.
350 '''
351 return self._filename_to_file(self.sourceparts_path, self.sourceparts_filename)
352
353 @property
354 def sourceparts_mode(self):
355 '''Mode of sourceparts file.
356
357 Note that add_source() will only use this mode if it creates a new file
358 for sourceparts_file; if the file already exists or if the SourceEntry is
359 saved in a different file, this mode is not used.
360 '''
361 return 0o644
362
363 def add_key(self):
364 '''Add the GPG key(s) corresponding to this repo.
365
366 By default, if self.trustedparts_content contains content,
367 and self.trustedparts_file points to a file, the key(s) will
368 be added to the file.
369
370 If the file does not yet exist, and self.trustedparts_mode is set,
371 the file will be created with that mode.
372 '''
373 if not all((self.trustedparts_file, self.trustedparts_content)):
374 return
375
376 dest = self.trustedparts_file
377 keys = self.trustedparts_content
378 if not isinstance(keys, bytes):
379 keys = keys.encode()
380 fp = self.fingerprints(keys)
381
382 print(_("Adding key to %s with fingerprint %s") % (dest, ','.join(fp)))
383
384 cmd = GPG_KEYRING_CMD % dest
385 action = "--import"
386 if not self.dry_run:
387 if not os.path.exists(dest) and self.trustedparts_mode:
388 with open(dest, 'wb'):
389 os.chmod(dest, self.trustedparts_mode)
390 try:
391 with tempfile.TemporaryDirectory() as homedir:
392 cmd += f" --homedir {homedir} {action}"
393 subprocess.run(cmd.split(), check=True, input=keys)
394 except subprocess.CalledProcessError as e:
395 raise ShortcutException(e)
396
397 def remove_key(self):
398 '''Remove the GPG key(s) corresponding to this repo.
399
400 By default, if self.trustedparts_content contains content,
401 and self.trustedparts_file points to a file, the key(s) will
402 be removed from the file.
403
404 If the file contains no more keys after removal, the file will
405 be removed.
406
407 This does not consider other files; multiple repositories may
408 use the same signing key. This only modifies/removes
409 self.trustedparts_file.
410 '''
411 if not all((self.trustedparts_file, self.trustedparts_content)):
412 return
413
414 dest = self.trustedparts_file
415 fp = self.fingerprints(self.trustedparts_content)
416
417 if not os.path.exists(dest):
418 return
419
420 print(_("Removing key from %s with fingerprint %s") % (dest, ','.join(fp)))
421
422 cmd = GPG_KEYRING_CMD % dest
423 action = "--delete-keys %s" % ' '.join(fp)
424 if not self.dry_run:
425 try:
426 with tempfile.TemporaryDirectory() as homedir:
427 cmd += f" --homedir {homedir} {action}"
428 subprocess.run(cmd.split(), check=True)
429 except subprocess.CalledProcessError as e:
430 raise ShortcutException(e)
431
432 with open(dest, 'rb') as f:
433 empty = not self.fingerprints(f.read())
434 if empty:
435 os.remove(dest)
436
437 @property
438 def trustedparts_path(self):
439 '''Return result of apt_pkg.config.find_dir("Dir::Etc::trustedparts")'''
440 return apt_pkg.config.find_dir("Dir::Etc::trustedparts")
441
442 @property
443 def trustedparts_filename(self):
444 '''Get the trusted.gpg.d filename, without the leading path.'''
445 return self._filebase_to_filename('gpg')
446
447 @property
448 def trustedparts_file(self):
449 '''Get the trusted.gpg.d absolute-path filename.'''
450 return self._filename_to_file(self.trustedparts_path, self.trustedparts_filename)
451
452 @property
453 def trustedparts_content(self):
454 '''Content to put into trusted.gpg.d file'''
455 return None
456
457 @property
458 def trustedparts_mode(self):
459 '''Mode of trustedparts file'''
460 return 0o644
461
462 def add_login(self):
463 '''Add the login credentials corresponding to this repo.
464
465 By default, if self.netrcparts_content contains content,
466 and self.netrcparts_file points to a file, the file will be
467 created and content placed into it.
468 '''
469 if not all((self.netrcparts_file, self.netrcparts_content)):
470 return
471
472 dest = self.netrcparts_file
473 content = self.netrcparts_content
474
475 newfile = not os.path.exists(dest)
476 finalchar = '\n'
477 if not newfile:
478 with open(dest, 'r') as f:
479 lines = [l.strip() for l in f.readlines()]
480 with suppress(KeyError):
481 finalchar = lines[-1][-1]
482 if all([l.strip() in lines for l in content.splitlines()]):
483 print(_("Authentication data already in %s") % dest)
484 return
485
486 print(_("Adding authentication data to %s") % dest)
487 if not self.dry_run:
488 if newfile and self.netrcparts_mode:
489 with open(dest, 'w'):
490 os.chmod(dest, self.netrcparts_mode)
491 with open(dest, 'a') as f:
492 # we're appending; if the file doesn't end in \n, throw one in
493 if finalchar != '\n':
494 f.write('\n')
495 f.write(self.netrcparts_content)
496
497 def remove_login(self):
498 '''Remove the login credentials corresponding to this repo.
499
500 By default, if self.netrcparts_content contains content,
501 and self.netrcparts_file points to a file, the content will
502 be removed from the file.
503
504 If the file is empty (other than whitespace) after removal, the file
505 will be removed.
506
507 This does not consider other files; this only modifies/removes
508 self.netrcparts_file.
509 '''
510 if not all((self.netrcparts_file, self.netrcparts_content)):
511 return
512
513 dest = self.netrcparts_file
514 content = set([l.strip() for l in self.netrcparts_content.splitlines()])
515
516 if not os.path.exists(dest):
517 return
518
519 with open(dest, 'r') as f:
520 filecontent = set([l.strip() for l in f.readlines()])
521 if not filecontent & content:
522 print(_("Authentication data not contained in %s") % dest)
523 else:
524 print(_("Removing authentication data from %s") % dest)
525 if not self.dry_run:
526 with open(dest, 'w') as f:
527 f.write('\n'.join(filecontent - content))
528
529 if not self.dry_run:
530 with open(dest, 'r') as f:
531 empty = not f.read().strip()
532 if empty:
533 os.remove(dest)
534
535 @property
536 def netrcparts_path(self):
537 '''Return result of apt_pkg.config.find_dir("Dir::Etc::netrcparts")'''
538 return apt_pkg.config.find_dir("Dir::Etc::netrcparts")
539
540 @property
541 def netrcparts_filename(self):
542 '''Get the auth.conf.d filename, without the leading path.'''
543 return self._filebase_to_filename('conf')
544
545 @property
546 def netrcparts_file(self):
547 '''Get the auth.conf.d absolute-path filename.'''
548 return self._filename_to_file(self.netrcparts_path, self.netrcparts_filename)
549
550 @property
551 def netrcparts_content(self):
552 '''Content to put into auth.conf.d file
553
554 By default, if both username and password are set, this will return a proper
555 netrc-formatted line with the authentication information, including the
556 hostname and path.
557 '''
558 if not all((self.username, self.password)):
559 return None
560
561 hostname = urlparse(self.SourceEntry().uri).hostname
562 path = urlparse(self.SourceEntry().uri).path
563 return f'machine {hostname}{path} login {self.username} password {self.password}'
564
565 @property
566 def netrcparts_mode(self):
567 '''Mode of netrcparts file'''
568 return 0o600
569
570 def _set_source_entry(self, line):
571 '''Set the SourceEntry.
572
573 This should be called from subclasses to set the SourceEntry.
574 The SourceEntry file will be set to the sourceparts_file value.
575
576 The self.components, if any, will be added to the line's component(s).
577 '''
578 e = SourceEntry(line)
579 e.comps = list(set(e.comps) | set(self.components))
580 self._source_entry = SourceEntry(str(e), file=self.sourceparts_file)
581
582 def _encode_filebase(self, suffix=None):
583 base = self._filebase
584 if not base:
585 return None
586 if suffix:
587 base += '-%s' % suffix
588 return re.sub("[^a-z0-9_-]+", "_", base.lower())
589
590 def _filebase_to_filename(self, ext, suffix=None):
591 base = self._encode_filebase(suffix=suffix)
592 if not base:
593 return None
594 return '%s.%s' % (base, ext)
595
596 def _filename_to_file(self, path, name):
597 if not name:
598 return None
599 return os.path.join(path, name)
600
601
602class ShortcutException(Exception):
603 '''General Exception during shortcut processing.'''
604 pass
605
606
607class InvalidShortcutException(ShortcutException):
608 '''Invalid shortcut.
609
610 This should only be thrown from the constructor of a ShortcutHandler
611 subclass, and only to indicate that the provided shortcut is invalid
612 for that ShortcutHandler class.
613 '''
614 pass
615
616
617# vi: ts=4 expandtab
diff --git a/softwareproperties/shortcuts.py b/softwareproperties/shortcuts.py
index c5f246c..f49321d 100644
--- a/softwareproperties/shortcuts.py
+++ b/softwareproperties/shortcuts.py
@@ -1,4 +1,4 @@
1# Copyright (c) 2013 Canonical Ltd.1# Copyright (c) 2013-2019 Canonical Ltd.
2#2#
3# Author: Scott Moser <smoser@ubuntu.com>3# Author: Scott Moser <smoser@ubuntu.com>
4#4#
@@ -17,41 +17,31 @@
17# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-130717# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
18# USA18# USA
1919
20import aptsources.distro
21from gettext import gettext as _20from gettext import gettext as _
2221
23_DEF_CODENAME = aptsources.distro.get_distro().codename22from softwareproperties.cloudarchive import CloudArchiveShortcutHandler
23from softwareproperties.ppa import PPAShortcutHandler
24from softwareproperties.shortcuthandler import InvalidShortcutException
25from softwareproperties.sourceslist import SourcesListShortcutHandler
26from softwareproperties.uri import URIShortcutHandler
2427
2528
26class ShortcutHandler(object):29SHORTCUT_HANDLERS = [
27 # the defeault ShortcutHandler only handles actual apt lines.30 PPAShortcutHandler,
28 # ie, 'shortcut' here is a line like you'd find in /etc/apt/sources.list:31 CloudArchiveShortcutHandler,
29 # deb MIRROR RELEASE-POCKET COMPONENT32 SourcesListShortcutHandler,
30 def __init__(self, shortcut):33 URIShortcutHandler,
31 self.shortcut = shortcut34]
3235
33 def add_key(self, keyserver=None):
34 return True
3536
36 def expand(self, codename=None, distro=None):37def shortcut_handler(shortcut, **kwargs):
37 return (self.shortcut, None)38 for handler in SHORTCUT_HANDLERS:
39 try:
40 return handler(shortcut, **kwargs)
41 except InvalidShortcutException:
42 pass
3843
39 def info(self):44 raise InvalidShortcutException(_("Unable to handle input '%s'") % shortcut)
40 return {
41 'description': _("No description available for '%(shortcut)s'") %
42 {'shortcut': self.shortcut},
43 'web_link': _("web link unavailable")}
4445
45 def should_confirm(self):
46 return False
47
48
49class ShortcutException(Exception):
50 pass
51
52
53def shortcut_handler(shortcut):
54 # this is the default shortcut handler, so it matches anything
55 return ShortcutHandler(shortcut)
5646
57# vi: ts=4 expandtab47# vi: ts=4 expandtab
diff --git a/softwareproperties/sourceslist.py b/softwareproperties/sourceslist.py
58new file mode 10064448new file mode 100644
index 0000000..407dc94
--- /dev/null
+++ b/softwareproperties/sourceslist.py
@@ -0,0 +1,56 @@
1# Copyright (c) 2019 Canonical Ltd.
2#
3# This program is free software; you can redistribute it and/or
4# modify it under the terms of the GNU General Public License as
5# published by the Free Software Foundation; either version 2 of the
6# License, or (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
16# USA
17
18from gettext import gettext as _
19
20from aptsources.sourceslist import SourceEntry
21
22from softwareproperties.shortcuthandler import (ShortcutHandler, InvalidShortcutException)
23
24from urllib.parse import urlparse
25
26
27SOURCESLIST_FILE_PREFIX = "archive_uri"
28
29class SourcesListShortcutHandler(ShortcutHandler):
30 def __init__(self, shortcut, ignore_line_comps=False, **kwargs):
31 super(SourcesListShortcutHandler, self).__init__(shortcut, **kwargs)
32
33 entry = SourceEntry(shortcut)
34 if entry.invalid:
35 raise InvalidShortcutException(_("Invalid sources.list line: '%s'") % shortcut)
36
37 uri = entry.uri
38 if not self.is_valid_uri(uri):
39 raise InvalidShortcutException(_("Invalid URI: '%s'") % uri)
40
41 # ignore_line_comps is used by URIShortcutHandler when no comps are provided
42 if not ignore_line_comps:
43 self.components = list(set(self.components) | set(entry.comps))
44
45 parsed = urlparse(uri)
46
47 self._username = parsed.username
48 self._password = parsed.password
49
50 entry.uri = self.uri_strip_auth(entry.uri)
51 # must set _filebase first; _set_source_entry uses it to set entry.file
52 self._filebase = f"{SOURCESLIST_FILE_PREFIX}-{entry.uri}"
53 self._set_source_entry(str(entry))
54
55
56# vi: ts=4 expandtab
diff --git a/softwareproperties/uri.py b/softwareproperties/uri.py
0new file mode 10064457new file mode 100644
index 0000000..a553a97
--- /dev/null
+++ b/softwareproperties/uri.py
@@ -0,0 +1,36 @@
1# Copyright (c) 2019 Canonical Ltd.
2#
3# This program is free software; you can redistribute it and/or
4# modify it under the terms of the GNU General Public License as
5# published by the Free Software Foundation; either version 2 of the
6# License, or (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
16# USA
17
18from aptsources.distro import get_distro
19
20from softwareproperties.sourceslist import SourcesListShortcutHandler
21
22
23class URIShortcutHandler(SourcesListShortcutHandler):
24 def __init__(self, shortcut, **kwargs):
25 (uri, _, comps) = shortcut.strip().partition(' ')
26
27 # can't use self.codename here, as we haven't called superclass constructor yet
28 distro = get_distro()
29 codename = kwargs.get('codename', distro.codename)
30
31 line = ('%s %s %s %s' % (distro.binary_type, uri, codename, comps or 'main'))
32
33 super(URIShortcutHandler, self).__init__(line, ignore_line_comps=not comps, **kwargs)
34
35
36# vi: ts=4 expandtab
diff --git a/tests/aptroot/etc/apt/apt.conf.d/.keep b/tests/aptroot/etc/apt/apt.conf.d/.keep
0deleted file mode 10064437deleted file mode 100644
index e69de29..0000000
--- a/tests/aptroot/etc/apt/apt.conf.d/.keep
+++ /dev/null
diff --git a/tests/test_aptsources.py b/tests/test_aptsources.py
index 83f28a1..13043ff 100755
--- a/tests/test_aptsources.py
+++ b/tests/test_aptsources.py
@@ -1,4 +1,4 @@
1#!/usr/bin/python1#!/usr/bin/python3
22
3from __future__ import print_function3from __future__ import print_function
44
diff --git a/tests/test_dbus.py b/tests/test_dbus.py
index 2505c1e..2099a72 100755
--- a/tests/test_dbus.py
+++ b/tests/test_dbus.py
@@ -1,4 +1,4 @@
1#!/usr/bin/python1#!/usr/bin/python3
2# -*- coding: utf-8 -*-2# -*- coding: utf-8 -*-
33
4from __future__ import print_function4from __future__ import print_function
@@ -7,6 +7,7 @@ from gi.repository import GLib, Gio
77
8import apt_pkg8import apt_pkg
9import aptsources.distro9import aptsources.distro
10import aptsources.sourceslist
1011
11import dbus12import dbus
12import logging13import logging
@@ -62,9 +63,8 @@ def clear_apt_config():
62 if os.path.isfile(path):63 if os.path.isfile(path):
63 os.unlink(path)64 os.unlink(path)
6465
65 if not os.path.exists(os.path.join(etc_apt, "apt.conf.d")):66 for d in ["apt.conf.d", "sources.list.d", "trusted.gpg.d", "auth.conf.d"]:
66 os.mkdir(os.path.join(etc_apt, "apt.conf.d"))67 os.makedirs(os.path.join(etc_apt, d), exist_ok=True)
67
6868
69def create_sources_list():69def create_sources_list():
70 s = get_test_source_line() + "\n"70 s = get_test_source_line() + "\n"
@@ -147,7 +147,7 @@ class TestDBus(unittest.TestCase):
147 # keep track of signal emissions147 # keep track of signal emissions
148 self.sources_list_count = 0148 self.sources_list_count = 0
149 self.distro_release = get_distro_release()149 self.distro_release = get_distro_release()
150 self.sources_list_path = create_sources_list()150 create_sources_list()
151 # create the client proxy151 # create the client proxy
152 bus = dbus.SessionBus(private=True, mainloop=DBusGMainLoop())152 bus = dbus.SessionBus(private=True, mainloop=DBusGMainLoop())
153 proxy = bus.get_object("com.ubuntu.SoftwareProperties", "/")153 proxy = bus.get_object("com.ubuntu.SoftwareProperties", "/")
@@ -164,10 +164,17 @@ class TestDBus(unittest.TestCase):
164 #print("_on_modified_sources_list")164 #print("_on_modified_sources_list")
165 self.sources_list_count += 1165 self.sources_list_count += 1
166166
167 @property
168 def sourceslist(self):
169 return ''.join([str(e) for e in aptsources.sourceslist.SourcesList()])
170
171 @property
172 def enabled_sourceslist(self):
173 return ''.join([str(e) for e in aptsources.sourceslist.SourcesList()
174 if not e.invalid and not e.disabled])
175
167 def _debug_sourceslist(self, text=""):176 def _debug_sourceslist(self, text=""):
168 with open(self.sources_list_path) as f:177 logging.debug("sourceslist: %s '%s'" % (text, self.sourceslist))
169 sourceslist = f.read()
170 logging.debug("sourceslist: %s '%s'" % (text, sourceslist))
171178
172 # this is an async call - give it a few seconds to catch up with what we expect179 # this is an async call - give it a few seconds to catch up with what we expect
173 def _assert_eventually(self, prop, n):180 def _assert_eventually(self, prop, n):
@@ -181,62 +188,44 @@ class TestDBus(unittest.TestCase):
181188
182 def test_enable_disable_component(self):189 def test_enable_disable_component(self):
183 # ensure its not there190 # ensure its not there
184 with open(self.sources_list_path) as f:191 self.assertNotIn("universe", self.sourceslist)
185 sourceslist = f.read()
186 self.assertFalse("universe" in sourceslist)
187 # enable192 # enable
188 self.iface.EnableComponent("universe")193 self.iface.EnableComponent("universe")
189 self._debug_sourceslist("2")194 self._debug_sourceslist("2")
190 with open(self.sources_list_path) as f:195 self.assertIn("universe", self.sourceslist)
191 sourceslist = f.read()
192 self.assertTrue("universe" in sourceslist)
193 # disable again196 # disable again
194 self.iface.DisableComponent("universe")197 self.iface.DisableComponent("universe")
195 self._debug_sourceslist("3")198 self._debug_sourceslist("3")
196 with open(self.sources_list_path) as f:199 self.assertNotIn("universe", self.sourceslist)
197 sourceslist = f.read()
198 self.assertFalse("universe" in sourceslist)
199 self._assert_eventually("sources_list_count", 2)200 self._assert_eventually("sources_list_count", 2)
200201
201 def test_enable_enable_disable_source_code_sources(self):202 def test_enable_enable_disable_source_code_sources(self):
202 # ensure its not there203 # ensure its not there
203 self._debug_sourceslist("4")204 self._debug_sourceslist("4")
204 with open(self.sources_list_path) as f:205 self.assertNotIn('deb-src', self.enabled_sourceslist)
205 sourceslist = f.read()
206 self.assertFalse("deb-src" in sourceslist)
207 # enable206 # enable
208 self.iface.EnableSourceCodeSources()207 self.iface.EnableSourceCodeSources()
209 self._debug_sourceslist("5")208 self._debug_sourceslist("5")
210 with open(self.sources_list_path) as f:209 self.assertIn('deb-src', self.enabled_sourceslist)
211 sourceslist = f.read()
212 self.assertTrue("deb-src" in sourceslist)
213 # disable again210 # disable again
214 self.iface.DisableSourceCodeSources()211 self.iface.DisableSourceCodeSources()
215 self._debug_sourceslist("6")212 self._debug_sourceslist("6")
216 with open(self.sources_list_path) as f:213 self.assertNotIn('deb-src', self.enabled_sourceslist)
217 sourceslist = f.read()
218 self.assertFalse("deb-src" in sourceslist)
219 self._assert_eventually("sources_list_count", 3)214 self._assert_eventually("sources_list_count", 3)
220215
221 def test_enable_child_source(self):216 def test_enable_child_source(self):
222 child_source = "%s-updates" % self.distro_release217 child_source = "%s-updates" % self.distro_release
223 # ensure its not there218 # ensure its not there
224 self._debug_sourceslist("7")219 self._debug_sourceslist("7")
225 with open(self.sources_list_path) as f:220 self.assertNotIn(child_source, self.sourceslist)
226 sourceslist = f.read()
227 self.assertFalse(child_source in sourceslist)
228 # enable221 # enable
229 self.iface.EnableChildSource(child_source)222 self.iface.EnableChildSource(child_source)
230 self._debug_sourceslist("8")223 self._debug_sourceslist("8")
231 with open(self.sources_list_path) as f:224 self.assertIn(child_source, self.sourceslist)
232 sourceslist = f.read()
233 self.assertTrue(child_source in sourceslist)
234 # disable again225 # disable again
235 self.iface.DisableChildSource(child_source)226 self.iface.DisableChildSource(child_source)
236 self._debug_sourceslist("9")227 self._debug_sourceslist("9")
237 with open(self.sources_list_path) as f:228 self.assertNotIn(child_source, self.sourceslist)
238 sourceslist = f.read()
239 self.assertFalse(child_source in sourceslist)
240 self._assert_eventually("sources_list_count", 2)229 self._assert_eventually("sources_list_count", 2)
241230
242 def test_toggle_source(self):231 def test_toggle_source(self):
@@ -244,17 +233,13 @@ class TestDBus(unittest.TestCase):
244 source = get_test_source_line()233 source = get_test_source_line()
245 self.iface.ToggleSourceUse(source)234 self.iface.ToggleSourceUse(source)
246 self._debug_sourceslist("10")235 self._debug_sourceslist("10")
247 with open(self.sources_list_path) as f:
248 sourceslist = f.read()
249 primary_debline = "# deb %s" % PRIMARY_MIRROR236 primary_debline = "# deb %s" % PRIMARY_MIRROR
250 self.assertTrue(primary_debline in sourceslist)237 self.assertIn(primary_debline, self.sourceslist)
251 # to disable the line again, we need to match the new "#"238 # to disable the line again, we need to match the new "#"
252 source = "# " + source239 source = "# " + source
253 self.iface.ToggleSourceUse(source)240 self.iface.ToggleSourceUse(source)
254 self._debug_sourceslist("11")241 self._debug_sourceslist("11")
255 with open(self.sources_list_path) as f:242 self.assertNotIn(primary_debline, self.sourceslist)
256 sourceslist = f.read()
257 self.assertFalse(primary_debline in sourceslist)
258243
259 self._assert_eventually("sources_list_count", 2)244 self._assert_eventually("sources_list_count", 2)
260245
@@ -264,10 +249,8 @@ class TestDBus(unittest.TestCase):
264 source_new = "deb http://xxx/ %s" % self.distro_release249 source_new = "deb http://xxx/ %s" % self.distro_release
265 self.iface.ReplaceSourceEntry(source, source_new)250 self.iface.ReplaceSourceEntry(source, source_new)
266 self._debug_sourceslist("11")251 self._debug_sourceslist("11")
267 with open(self.sources_list_path) as f:252 self.assertIn(source_new, self.sourceslist)
268 sourceslist = f.read()253 self.assertNotIn(source, self.sourceslist)
269 self.assertTrue(source_new in sourceslist)
270 self.assertFalse(source in sourceslist)
271 self._assert_eventually("sources_list_count", 1)254 self._assert_eventually("sources_list_count", 1)
272 self.iface.ReplaceSourceEntry(source_new, source)255 self.iface.ReplaceSourceEntry(source_new, source)
273 self._assert_eventually("sources_list_count", 2)256 self._assert_eventually("sources_list_count", 2)
@@ -278,19 +261,19 @@ class TestDBus(unittest.TestCase):
278 "aptroot", "etc", "popularity-contest.conf")261 "aptroot", "etc", "popularity-contest.conf")
279 with open(popcon_p) as f:262 with open(popcon_p) as f:
280 popcon = f.read()263 popcon = f.read()
281 self.assertTrue('PARTICIPATE="no"' in popcon)264 self.assertIn('PARTICIPATE="no"', popcon)
282 # toggle265 # toggle
283 self.iface.SetPopconPariticipation(True)266 self.iface.SetPopconPariticipation(True)
284 with open(popcon_p) as f:267 with open(popcon_p) as f:
285 popcon = f.read()268 popcon = f.read()
286 self.assertTrue('PARTICIPATE="yes"' in popcon)269 self.assertIn('PARTICIPATE="yes"', popcon)
287 self.assertFalse('PARTICIPATE="no"' in popcon)270 self.assertNotIn('PARTICIPATE="no"', popcon)
288 # and back271 # and back
289 self.iface.SetPopconPariticipation(False)272 self.iface.SetPopconPariticipation(False)
290 with open(popcon_p) as f:273 with open(popcon_p) as f:
291 popcon = f.read()274 popcon = f.read()
292 self.assertFalse('PARTICIPATE="yes"' in popcon)275 self.assertNotIn('PARTICIPATE="yes"', popcon)
293 self.assertTrue('PARTICIPATE="no"' in popcon)276 self.assertIn('PARTICIPATE="no"', popcon)
294277
295 def test_updates_automation(self):278 def test_updates_automation(self):
296 states = [UPDATE_INST_SEC, UPDATE_DOWNLOAD, UPDATE_NOTIFY]279 states = [UPDATE_INST_SEC, UPDATE_DOWNLOAD, UPDATE_NOTIFY]
@@ -301,19 +284,19 @@ class TestDBus(unittest.TestCase):
301 "10periodic")284 "10periodic")
302 with open(cfg) as f:285 with open(cfg) as f:
303 config = f.read()286 config = f.read()
304 self.assertTrue('APT::Periodic::Unattended-Upgrade "1";' in config)287 self.assertIn('APT::Periodic::Unattended-Upgrade "1";', config)
305 # download288 # download
306 self.iface.SetUpdateAutomationLevel(states[1])289 self.iface.SetUpdateAutomationLevel(states[1])
307 with open(cfg) as f:290 with open(cfg) as f:
308 config = f.read()291 config = f.read()
309 self.assertTrue('APT::Periodic::Unattended-Upgrade "0";' in config)292 self.assertIn('APT::Periodic::Unattended-Upgrade "0";', config)
310 self.assertTrue('APT::Periodic::Download-Upgradeable-Packages "1";' in config)293 self.assertIn('APT::Periodic::Download-Upgradeable-Packages "1";', config)
311 # notify294 # notify
312 self.iface.SetUpdateAutomationLevel(states[2])295 self.iface.SetUpdateAutomationLevel(states[2])
313 with open(cfg) as f:296 with open(cfg) as f:
314 config = f.read()297 config = f.read()
315 self.assertTrue('APT::Periodic::Unattended-Upgrade "0";' in config)298 self.assertIn('APT::Periodic::Unattended-Upgrade "0";', config)
316 self.assertTrue('APT::Periodic::Download-Upgradeable-Packages "0";' in config)299 self.assertIn('APT::Periodic::Download-Upgradeable-Packages "0";', config)
317300
318 def test_updates_interval(self):301 def test_updates_interval(self):
319 # interval302 # interval
@@ -329,30 +312,26 @@ class TestDBus(unittest.TestCase):
329 self.iface.SetUpdateInterval(1)312 self.iface.SetUpdateInterval(1)
330 with open(cfg) as f:313 with open(cfg) as f:
331 config = f.read()314 config = f.read()
332 self.assertTrue('APT::Periodic::Update-Package-Lists "1";' in config)315 self.assertIn('APT::Periodic::Update-Package-Lists "1";', config)
333 self.iface.SetUpdateInterval(0)316 self.iface.SetUpdateInterval(0)
334 with open(cfg) as f:317 with open(cfg) as f:
335 config = f.read()318 config = f.read()
336 self.assertTrue('APT::Periodic::Update-Package-Lists "0";' in config)319 self.assertIn('APT::Periodic::Update-Package-Lists "0";', config)
337320
338 def test_add_remove_source_by_line(self):321 def test_add_remove_source_by_line(self):
339 # add invalid322 # add invalid
340 res = self.iface.AddSourceFromLine("xxx")323 res = self.iface.AddSourceFromLine("xxx")
341 self.assertFalse(res)324 self.assertFalse(res)
342 # add real325 # add real
343 s = "deb http//ppa.launchpad.net/ foo bar"326 s = "deb http://ppa.launchpad.net/ foo bar"
344 self.iface.AddSourceFromLine(s)327 self.iface.AddSourceFromLine(s)
345 with open(self.sources_list_path) as f:328 self.assertIn(s, self.sourceslist)
346 sourceslist = f.read()329 self.assertIn(s.replace("deb", "# deb-src"), self.sourceslist)
347 self.assertTrue(s in sourceslist)
348 self.assertTrue(s.replace("deb", "# deb-src") in sourceslist)
349 # remove again330 # remove again
350 self.iface.RemoveSource(s)331 self.iface.RemoveSource(s)
351 self.iface.RemoveSource(s.replace("deb", "deb-src"))332 self.iface.RemoveSource(s.replace("deb", "deb-src"))
352 with open(self.sources_list_path) as f:333 self.assertNotIn(s, self.sourceslist)
353 sourceslist = f.read()334 self.assertNotIn(s.replace("deb", "# deb-src"), self.sourceslist)
354 self.assertFalse(s in sourceslist)
355 self.assertFalse(s.replace("deb", "# deb-src") in sourceslist)
356 self._assert_eventually("sources_list_count", 4)335 self._assert_eventually("sources_list_count", 4)
357336
358 def test_add_gpg_key(self):337 def test_add_gpg_key(self):
diff --git a/tests/test_lp.py b/tests/test_lp.py
359deleted file mode 100755338deleted file mode 100755
index a732ab0..0000000
--- a/tests/test_lp.py
+++ /dev/null
@@ -1,167 +0,0 @@
1#!/usr/bin/python
2
3import apt_pkg
4
5import os
6import unittest
7import sys
8sys.path.insert(0, "..")
9
10from mock import patch
11
12import softwareproperties.ppa
13from softwareproperties.ppa import (
14 AddPPASigningKeyThread,
15 mangle_ppa_shortcut,
16 verify_keyid_is_v4,
17 )
18
19
20MOCK_PPA_INFO={
21 "displayname": "PPA for Michael Vogt",
22 "web_link": "https://launchpad.net/~mvo/+archive/ppa",
23 "signing_key_fingerprint": "019A25FED88F961763935D7F129196470EB12F05",
24 "name": "ppa",
25 'distribution_link': 'https://launchpad.net/api/1.0/ubuntu',
26 'owner_link': 'https://launchpad.net/api/1.0/~mvo',
27 'reference': '~mvo/ubuntu/ppa',
28 'self_link': 'https://launchpad.net/api/devel/~mvo/+archive/ubuntu/ppa',
29 }
30
31MOCK_KEY="""
32-----BEGIN PGP PUBLIC KEY BLOCK-----
33Version: SKS 1.1.6
34Comment: Hostname: keyserver.ubuntu.com
35
36mI0ESXP67wEEAN2m3xWkAP0p1erHbJx1wYBCL6tLqWXESx1BmF0htLzdD9lfsUYiNs+Zgg3w
37uU0PrQIcqZtyTESh514tw3KQ+OAK2I0a2XJR99lXPksiKoxaOOsr0pTVWDYuIlfV3yfmXvnK
38FZSmaMjjKuqQbCwZe8Ev7yry9Gh9pM5Y87MbNT05ABEBAAG0HkxhdW5jaHBhZCBQUEEgZm9y
39IE1pY2hhZWwgVm9ndIi2BBMBAgAgBQJJc/rvAhsDBgsJCAcDAgQVAggDBBYCAwECHgECF4AA
40CgkQEpGWRw6xLwVofAP/YyU3YykXbr8p7wRp1EpFlDmtbPlFXp00gt4Cqlu2AWVOkwkVoMRQ
41Ncb7wog2Z6u7KyUhD8pgC2FEL0+FQjyNemv7D0OYBG+6DLdjtRsv0CumLdWFmviU96j3OcwT
42G2GkIC/eB2maTrV/vj7vlZ0Qe/T1NL6XLpr0A6Rg6JAtkFM=
43=SMbJ
44-----END PGP PUBLIC KEY BLOCK-----
45"""
46
47MOCK_SECOND_KEY="""
48-----BEGIN PGP PUBLIC KEY BLOCK-----
49Version: SKS 1.1.6
50Comment: Hostname: keyserver.ubuntu.com
51
52mI0ESX34EgEEAOTzplZO3TXmb9dRLu7kOuIEia21e4gwQ/RQe+LD7HdhikcETjf2Ruu0mn6S
53sgPLL+duhKxmv6ZciLUgkk0qEDCZuR6BPxdgAIwqmQmFipcv6UTMQitRPUa9WlPU37Qg+joL
54cTBUdamnVq+yJhLmnuO44UWAty85nNJzDd29gxqXABEBAAG0LUxhdW5jaHBhZCBQUEEgZm9y
55INCU0LzQuNGC0YDQuNC5INCb0LXQtNC60L7Qsoi2BBMBAgAgBQJJffgSAhsDBgsJCAcDAgQV
56AggDBBYCAwECHgECF4AACgkQFXlR/kAx0oeuSwQAuhhgWgeeG3F9XMYDqgJShzMSeQOLMKBq
576mNFEL1sDhRdbinf7rwuQFXDSSNCj8/PLa3DF/u09tAm6CTi10iwxxbXf16pTq21gxCA3/xS
58fszv352yZpcN85MD5aozqv7qUCGOQ9Gey7JzgD7L4wMEjyRScVjx1chfLgyapdj822E=
59=pdql
60-----END PGP PUBLIC KEY BLOCK-----
61"""
62
63class LaunchpadPPATestCase(unittest.TestCase):
64
65 @classmethod
66 def setUpClass(cls):
67 for k in apt_pkg.config.keys():
68 apt_pkg.config.clear(k)
69 apt_pkg.init()
70
71 @unittest.skipUnless(
72 "TEST_ONLINE" in os.environ,
73 "skipping online tests unless TEST_ONLINE environment variable is set")
74 @unittest.skipUnless(
75 sys.version_info[0] > 2,
76 "pycurl doesn't raise SSL exceptions anymore it seems")
77 def test_ppa_info_from_lp(self):
78 # use correct data
79 info = softwareproperties.ppa.get_ppa_info_from_lp("mvo", "ppa")
80 self.assertNotEqual(info, {})
81 self.assertEqual(info["name"], "ppa")
82 # use empty CERT file
83 softwareproperties.ppa.LAUNCHPAD_PPA_CERT = "/dev/null"
84 with self.assertRaises(Exception):
85 softwareproperties.ppa.get_ppa_info_from_lp("mvo", "ppa")
86
87 def test_mangle_ppa_shortcut(self):
88 self.assertEqual("~mvo/ubuntu/ppa", mangle_ppa_shortcut("ppa:mvo"))
89 self.assertEqual(
90 "~mvo/ubuntu/compiz", mangle_ppa_shortcut("ppa:mvo/compiz"))
91 self.assertEqual(
92 "~mvo/ubuntu-rtm/compiz",
93 mangle_ppa_shortcut("ppa:mvo/ubuntu-rtm/compiz"))
94
95 def test_mangle_ppa_shortcut_leading_slash(self):
96 # Test for LP: #1426933
97 self.assertEqual("~gottcode/ubuntu/gcppa",
98 mangle_ppa_shortcut("ppa:/gottcode/gcppa"))
99
100 def test_mangle_ppa_supports_no_ppa_colon_prefix(self):
101 """mangle_ppa should also support input without 'ppa:'."""
102 self.assertEqual("~mvo/ubuntu/ppa", mangle_ppa_shortcut("~mvo/ppa"))
103
104
105class AddPPASigningKeyTestCase(unittest.TestCase):
106
107 @classmethod
108 def setUpClass(cls):
109 for k in apt_pkg.config.keys():
110 apt_pkg.config.clear(k)
111 apt_pkg.init()
112 cls.trustedgpg = os.path.join(
113 os.path.dirname(__file__), "aptroot", "etc", "apt", "trusted.gpg.d")
114 try:
115 os.makedirs(cls.trustedgpg)
116 except:
117 pass
118
119 def setUp(self):
120 self.t = AddPPASigningKeyThread("~mvo/ubuntu/ppa")
121
122 @patch("softwareproperties.ppa.get_ppa_info_from_lp")
123 @patch("softwareproperties.ppa.subprocess")
124 def test_fingerprint_len_check(self, mock_subprocess, mock_get_ppa_info):
125 """Test that short keyids (<160bit) are rejected"""
126 mock_ppa_info = MOCK_PPA_INFO.copy()
127 mock_ppa_info["signing_key_fingerprint"] = "0EB12F05"
128 mock_get_ppa_info.return_value = mock_ppa_info
129 # do it
130 res = self.t.add_ppa_signing_key("~mvo/ubuntu/ppa")
131 self.assertFalse(res)
132 self.assertFalse(mock_subprocess.Popen.called)
133 self.assertFalse(mock_subprocess.call.called)
134
135 @patch("softwareproperties.ppa.get_ppa_info_from_lp")
136 @patch("softwareproperties.ppa.get_info_from_https")
137 def test_add_ppa_signing_key_wrong_fingerprint(self, mock_https, mock_get_ppa_info):
138 mock_get_ppa_info.return_value = MOCK_PPA_INFO
139 mock_https.return_value = MOCK_SECOND_KEY
140 res = self.t.add_ppa_signing_key("~mvo/ubuntu/ppa")
141 self.assertFalse(res)
142
143 @patch("softwareproperties.ppa.get_ppa_info_from_lp")
144 @patch("softwareproperties.ppa.get_info_from_https")
145 def test_add_ppa_signing_key_multiple_fingerprints(self, mock_https, mock_get_ppa_info):
146 mock_get_ppa_info.return_value = MOCK_PPA_INFO
147 mock_https.return_value = '\n'.join([MOCK_KEY, MOCK_SECOND_KEY])
148 res = self.t.add_ppa_signing_key("~mvo/ubuntu/ppa")
149 self.assertFalse(res)
150
151 @patch("softwareproperties.ppa.get_ppa_info_from_lp")
152 @patch("softwareproperties.ppa.get_info_from_https")
153 @patch("apt_pkg.config")
154 def test_add_ppa_signing_key_ok(self, mock_config, mock_https, mock_get_ppa_info):
155 mock_get_ppa_info.return_value = MOCK_PPA_INFO
156 mock_https.return_value = MOCK_KEY
157 mock_config.find_dir.return_value = self.trustedgpg
158 res = self.t.add_ppa_signing_key("~mvo/ubuntu/ppa")
159 self.assertTrue(res)
160
161 def test_verify_keyid_is_v4(self):
162 keyid = "0EB12F05"
163 self.assertFalse(verify_keyid_is_v4(keyid))
164
165
166if __name__ == "__main__":
167 unittest.main()
diff --git a/tests/test_pyflakes.py b/tests/test_pyflakes.py
index b066e2d..83aef4a 100755
--- a/tests/test_pyflakes.py
+++ b/tests/test_pyflakes.py
@@ -3,7 +3,6 @@ import os
3import subprocess3import subprocess
4import unittest4import unittest
55
6@unittest.skip("It's not clean")
7class TestPyflakesClean(unittest.TestCase):6class TestPyflakesClean(unittest.TestCase):
8 """ ensure that the tree is pyflakes clean """7 """ ensure that the tree is pyflakes clean """
98
diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py
index 1338e25..bf6651d 100644
--- a/tests/test_shortcuts.py
+++ b/tests/test_shortcuts.py
@@ -1,34 +1,106 @@
1#!/usr/bin/python1#!/usr/bin/python3
22
3import apt3import apt
44
5import unittest5import unittest
6import sys6import sys
7try:7import os
8 from urllib.request import urlopen8
9 from urllib.error import HTTPError, URLError9from aptsources.distro import get_distro
10except ImportError:10from aptsources.sourceslist import SourceEntry
11 from urllib2 import HTTPError, URLError, urlopen11from contextlib import contextmanager
12try:12from http.client import HTTPException
13 from http.client import HTTPException13from launchpadlib.launchpad import Launchpad
14except ImportError:14from mock import (patch, Mock)
15 from httplib import HTTPException15from urllib.request import urlopen
16from urllib.error import URLError
1617
17sys.path.insert(0, "..")18sys.path.insert(0, "..")
1819
19from softwareproperties.SoftwareProperties import shortcut_handler20from softwareproperties.sourceslist import SourcesListShortcutHandler
20from softwareproperties.shortcuts import ShortcutException21from softwareproperties.uri import URIShortcutHandler
21from mock import patch22from softwareproperties.cloudarchive import CloudArchiveShortcutHandler
23from softwareproperties.ppa import PPAShortcutHandler
24from softwareproperties.shortcuthandler import InvalidShortcutException
25from softwareproperties.shortcuts import shortcut_handler
26
27
28DISTRO = get_distro()
29CODENAME = DISTRO.codename
30
31# These must match the ppa used in the VALID_PPAS
32PPA_LINE = f"deb http://ppa.launchpad.net/ddstreet/ppa/ubuntu/ {CODENAME} main"
33PPA_FILEBASE = "ddstreet-ubuntu-ppa"
34PPA_SOURCEFILE = f"{PPA_FILEBASE}-{CODENAME}.list"
35PPA_TRUSTEDFILE = f"{PPA_FILEBASE}.gpg"
36PPA_NETRCFILE = f"{PPA_FILEBASE}.conf"
37
38PRIVATE_PPA_PASSWORD = "thisisnotarealpassword"
39PRIVATE_PPA_LINE = f"deb https://private-ppa.launchpad.net/ddstreet/ppa/ubuntu/ {CODENAME} main"
40PRIVATE_PPA_NETRCCONTENT = f"machine private-ppa.launchpad.net/ddstreet/ppa/ubuntu/ login ddstreet password {PRIVATE_PPA_PASSWORD}"
41PRIVATE_PPA_SUBSCRIPTION_URLS = [f"https://ddstreet:{PRIVATE_PPA_PASSWORD}@private-ppa.launchpad.net/ddstreet/ppa/ubuntu/"]
42
43# These must match the uca used in VALID_UCAS
44UCA_CANAME = "train"
45UCA_ARCHIVE = "http://ubuntu-cloud.archive.canonical.com/ubuntu"
46UCA_LINE = f"deb {UCA_ARCHIVE} bionic-updates/{UCA_CANAME} main"
47UCA_LINE_PROPOSED = f"deb {UCA_ARCHIVE} bionic-proposed/{UCA_CANAME} main"
48UCA_FILEBASE = f"cloudarchive-{UCA_CANAME}"
49UCA_SOURCEFILE = f"{UCA_FILEBASE}.list"
50CA_ALLOW_CODENAME = "bionic"
51
52# This must match the VALID_URIS
53URI = "http://fake.mirror.private.com/ubuntu"
54URI_FILEBASE = f"archive_uri-http_fake_mirror_private_com_ubuntu"
55URI_SOURCEFILE = f"{URI_FILEBASE}-{CODENAME}.list"
56
57VALID_LINES = [f"deb {URI} bionic main"]
58VALID_URIS = [URI]
59VALID_PPAS = ["ppa:ddstreet", "ppa:~ddstreet", "ppa:ddstreet/ppa", "ppa:~ddstreet/ppa", "ppa:ddstreet/ubuntu/ppa", "ppa:~ddstreet/ubuntu/ppa"]
60VALID_UCAS = [f"cloud-archive:{UCA_CANAME}", f"cloud-archive:{UCA_CANAME}-updates", f"cloud-archive:{UCA_CANAME}-proposed", f"uca:{UCA_CANAME}", f"uca:{UCA_CANAME}-updates", f"uca:{UCA_CANAME}-proposed"]
61VALID_ALL = VALID_LINES + VALID_URIS + VALID_PPAS + VALID_UCAS
62
63INVALID_LINES = ["xxx invalid deb line"]
64INVALID_URIS = ["invalid"]
65INVALID_PPAS = ["ppainvalid:ddstreet", "ppa:ddstreet/ubuntu/ppa/invalid"]
66INVALID_UCAS = [f"cloud-invalid:{UCA_CANAME}", "cloud-archive:"]
67INVALID_ALL = INVALID_LINES + INVALID_URIS + INVALID_PPAS + INVALID_UCAS
2268
23def has_network():69def has_network():
24 try:70 try:
25 network = urlopen("https://launchpad.net/")71 with urlopen("https://launchpad.net/"):
26 network72 pass
27 except (URLError, HTTPException):73 except (URLError, HTTPException):
28 return False74 return False
29 return True75 return True
3076
77def mock_login_with(*args, **kwargs):
78 _lp = Launchpad.login_anonymously(*args, **kwargs)
79 lp = Mock(wraps=_lp)
80
81 lp.me = Mock()
82 lp.me.name = 'ddstreet'
83 lp.me.getArchiveSubscriptionURLs = lambda: PRIVATE_PPA_SUBSCRIPTION_URLS
84
85 def mock_getPPAByName(_team, name):
86 _ppa = _team.getPPAByName(name=name)
87 ppa = Mock(wraps=_ppa)
88 ppa.signing_key_fingerprint = _ppa.signing_key_fingerprint
89 ppa.private = True
90 return ppa
91
92 def mock_people(teamname):
93 _team = _lp.people(teamname)
94 team = Mock(wraps=_team)
95 team.getPPAByName = lambda name: mock_getPPAByName(_team, name)
96 return team
97
98 lp.people = mock_people
99 return lp
100
101
31class ShortcutsTestcase(unittest.TestCase):102class ShortcutsTestcase(unittest.TestCase):
103 enable_source = False
32104
33 @classmethod105 @classmethod
34 def setUpClass(cls):106 def setUpClass(cls):
@@ -42,37 +114,133 @@ class ShortcutsTestcase(unittest.TestCase):
42 apt.apt_pkg.config.set("Dir::Etc", "etc/apt")114 apt.apt_pkg.config.set("Dir::Etc", "etc/apt")
43 apt.apt_pkg.config.set("Dir::Etc::sourcelist", "sources.list")115 apt.apt_pkg.config.set("Dir::Etc::sourcelist", "sources.list")
44 apt.apt_pkg.config.set("Dir::Etc::sourceparts", "sources.list.d")116 apt.apt_pkg.config.set("Dir::Etc::sourceparts", "sources.list.d")
117 apt.apt_pkg.config.set("Dir::Etc::trustedparts", "trusted.gpg.d")
118 apt.apt_pkg.config.set("Dir::Etc::netrcparts", "auth.conf.d")
119
120 def create_handler(self, line, handler, *args, **kwargs):
121 return handler(line, *args, enable_source=self.enable_source, **kwargs)
45122
46 def test_shortcut_none(self):123 def create_handlers(self, line, handler, *args, **kwargs):
47 line = "deb http://ubuntu.com/ubuntu trusty main"124 handlers = handler if isinstance(handler, list) else [handler]
48 handler = shortcut_handler(line)125 # note, always appends shortcut_handler
49 self.assertEqual((line, None), handler.expand())126 return [self.create_handler(line, handler, *args, **kwargs)
127 for handler in handlers + [shortcut_handler]]
128
129 def check_shortcut(self, shortcut, line, sourcefile=None, trustedfile=None, netrcfile=None,
130 sourceparts=apt.apt_pkg.config.find_dir("Dir::Etc::sourceparts"),
131 trustedparts=apt.apt_pkg.config.find_dir("Dir::Etc::trustedparts"),
132 netrcparts=apt.apt_pkg.config.find_dir("Dir::Etc::netrcparts"),
133 trustedcontent=False, netrccontent=None):
134 self.assertEqual(shortcut.SourceEntry().line, line)
135
136 self.assertEqual(shortcut.sourceparts_path, sourceparts)
137 if sourcefile:
138 self.assertEqual(shortcut.SourceEntry().file, os.path.join(sourceparts, sourcefile))
139 self.assertEqual(shortcut.sourceparts_filename, sourcefile)
140 self.assertEqual(shortcut.sourceparts_file, os.path.join(sourceparts, sourcefile))
141
142 binentry = SourceEntry(line)
143 binentry.type = DISTRO.binary_type
144 self.assertEqual(shortcut.SourceEntry(shortcut.binary_type), binentry)
145
146 srcentry = SourceEntry(line)
147 srcentry.type = DISTRO.source_type
148 srcentry.set_enabled(self.enable_source)
149 self.assertEqual(shortcut.SourceEntry(shortcut.source_type), srcentry)
150
151 self.assertEqual(shortcut.trustedparts_path, trustedparts)
152 if trustedfile:
153 self.assertEqual(shortcut.trustedparts_filename, trustedfile)
154 self.assertEqual(shortcut.trustedparts_file, os.path.join(trustedparts, trustedfile))
155
156 # Checking the actual gpg key content is too much work.
157 if trustedcontent:
158 self.assertIsNotNone(shortcut.trustedparts_content)
159 else:
160 self.assertIsNone(shortcut.trustedparts_content)
161
162 self.assertEqual(shortcut.netrcparts_path, netrcparts)
163 if netrcfile:
164 self.assertEqual(shortcut.netrcparts_filename, netrcfile)
165 self.assertEqual(shortcut.netrcparts_file, os.path.join(netrcparts, netrcfile))
166
167 self.assertEqual(shortcut.netrcparts_content, netrccontent)
168
169 def test_shortcut_sourceslist(self):
170 for line in VALID_LINES:
171 for shortcut in self.create_handlers(line, SourcesListShortcutHandler):
172 self.check_shortcut(shortcut, line)
173
174 def test_shortcut_uri(self):
175 for uri in VALID_URIS:
176 line = f"deb {uri} {CODENAME} main"
177 for shortcut in self.create_handlers(uri, URIShortcutHandler):
178 self.check_shortcut(shortcut, line, sourcefile=URI_SOURCEFILE)
50179
51 @unittest.skipUnless(has_network(), "requires network")180 @unittest.skipUnless(has_network(), "requires network")
52 def test_shortcut_ppa(self):181 def test_shortcut_ppa(self):
53 line = "ppa:mvo"182 for ppa in VALID_PPAS:
54 handler = shortcut_handler(line)183 for shortcut in self.create_handlers(ppa, PPAShortcutHandler):
55 self.assertEqual(184 self.check_shortcut(shortcut, PPA_LINE,
56 ('deb http://ppa.launchpad.net/mvo/ppa/ubuntu trusty main',185 sourcefile=PPA_SOURCEFILE,
57 '/etc/apt/sources.list.d/mvo-ubuntu-ppa-trusty.list'),186 trustedfile=PPA_TRUSTEDFILE,
58 handler.expand("trusty", distro="ubuntu"))187 netrcfile=PPA_NETRCFILE,
188 trustedcontent=True)
59189
60 @unittest.skipUnless(has_network(), "requires network")190 @unittest.skipUnless(has_network(), "requires network")
191 def test_shortcut_private_ppa(self):
192 # this is the same tests as the public ppa, but login=True will use the mocked lp instance
193 # this *does not* actually test/verify this works with a real private ppa; that must be done manually
194 with patch('launchpadlib.launchpad.Launchpad.login_with', new=mock_login_with):
195 for ppa in VALID_PPAS:
196 for shortcut in self.create_handlers(ppa, PPAShortcutHandler, login=True):
197 self.check_shortcut(shortcut, PRIVATE_PPA_LINE,
198 sourcefile=PPA_SOURCEFILE,
199 trustedfile=PPA_TRUSTEDFILE,
200 netrcfile=PPA_NETRCFILE,
201 trustedcontent=True,
202 netrccontent=PRIVATE_PPA_NETRCCONTENT)
203
204 @contextmanager
205 def ca_allow_codename(self, codename):
206 key = "CA_ALLOW_CODENAME"
207 orig = os.environ.get(key, None)
208 try:
209 os.environ[key] = codename
210 yield
211 finally:
212 if orig:
213 os.environ[key] = orig
214 else:
215 os.environ.pop(key, None)
216
61 def test_shortcut_cloudarchive(self):217 def test_shortcut_cloudarchive(self):
62 line = "cloud-archive:folsom"218 for uca in VALID_UCAS:
63 handler = shortcut_handler(line)219 line = UCA_LINE_PROPOSED if 'proposed' in uca else UCA_LINE
64 self.assertEqual(220 with self.ca_allow_codename(CA_ALLOW_CODENAME):
65 ('deb http://ubuntu-cloud.archive.canonical.com/ubuntu '\221 for shortcut in self.create_handlers(uca, CloudArchiveShortcutHandler):
66 'precise-updates/folsom main',222 self.check_shortcut(shortcut, line, sourcefile=UCA_SOURCEFILE)
67 '/etc/apt/sources.list.d/cloudarchive-folsom.list'),223
68 handler.expand("precise", distro="ubuntu"))224 def check_invalid_shortcut(self, handler, shortcut):
69225 msg = "'%s' should have rejected '%s'" % (handler, shortcut)
70 def test_shortcut_exception(self):226 with self.assertRaises(InvalidShortcutException, msg=msg):
71 with self.assertRaises(ShortcutException):227 self.create_handler(shortcut, handler)
72 with patch('softwareproperties.ppa.get_ppa_info_from_lp',228
73 side_effect=lambda *args: HTTPError("url", 404, "not found", [], None)):229 def test_shortcut_invalid(self):
74 shortcut_handler("ppa:mvo")230 for s in INVALID_ALL + VALID_URIS + VALID_PPAS + VALID_UCAS:
231 self.check_invalid_shortcut(SourcesListShortcutHandler, s)
232 for s in INVALID_ALL + VALID_LINES + VALID_PPAS + VALID_UCAS:
233 self.check_invalid_shortcut(URIShortcutHandler, s)
234 for s in INVALID_ALL + VALID_LINES + VALID_URIS + VALID_UCAS:
235 self.check_invalid_shortcut(PPAShortcutHandler, s)
236 for s in INVALID_ALL + VALID_LINES + VALID_URIS + VALID_PPAS:
237 self.check_invalid_shortcut(CloudArchiveShortcutHandler, s)
238 for s in INVALID_ALL:
239 self.check_invalid_shortcut(shortcut_handler, s)
240
75241
242class EnableSourceShortcutsTestcase(ShortcutsTestcase):
243 enable_source = True
76244
77245
78if __name__ == "__main__":246if __name__ == "__main__":
diff --git a/tests/test_sp.py b/tests/test_sp.py
index b372f66..f6bc8fc 100644
--- a/tests/test_sp.py
+++ b/tests/test_sp.py
@@ -1,4 +1,4 @@
1#!/usr/bin/python1#!/usr/bin/python3
2# -*- coding: utf-8 -*-2# -*- coding: utf-8 -*-
33
4import apt_pkg4import apt_pkg

Subscribers

People subscribed via source and target branches