Merge ppa-dev-tools:tests-dashboard into ppa-dev-tools:main

Proposed by Bryce Harrington
Status: Merged
Approved by: Bryce Harrington
Approved revision: 81e5433dcb506903f321d6d0fb99a681f11991ba
Merge reported by: Bryce Harrington
Merged at revision: 81e5433dcb506903f321d6d0fb99a681f11991ba
Proposed branch: ppa-dev-tools:tests-dashboard
Merge into: ppa-dev-tools:main
Diff against target: 963 lines (+363/-120)
16 files modified
NEWS.md (+7/-0)
README.md (+24/-0)
ppa/debug.py (+14/-8)
ppa/job.py (+20/-20)
ppa/ppa.py (+16/-3)
ppa/ppa_group.py (+15/-7)
ppa/result.py (+11/-6)
ppa/subtest.py (+7/-4)
ppa/text.py (+14/-29)
ppa/trigger.py (+5/-3)
scripts/ppa (+92/-34)
tests/test_job.py (+2/-2)
tests/test_ppa_group.py (+6/-1)
tests/test_result.py (+1/-1)
tests/test_scripts_ppa.py (+75/-2)
tests/test_text.py (+54/-0)
Reviewer Review Type Date Requested Status
Athos Ribeiro (community) Approve
Ubuntu Server Pending
Canonical Server Reporter Pending
Review via email: mp+432123@code.launchpad.net

Description of the change

I am still running a version of terminator that does not support the
ansi hyperlink functionality, and for that reason was planning on
implementing something to test the terminal for support, and make
`ppa tests` automatically format links.

However, in researching into it, I see that once I upgrade to jammy's
version of Terminator I'll have the support, and a lot of other terminal
programs also already include it now. Furthermore, I've failed to find
if there's a provided way to check for support, other than just looking
up the terminal program's version number against a known-good list.

So rather than bother with all that logic (that'll become less and less
necessary over time), I'll just follow lp-test-ppa's solution of relying
on commandline params / config values for users to manually select what
they want, and leave it at that.

Anyway, by and large this is derivative of lp-test-ppa's output
formatting, but I've added some refinements. First, I've structured
the bulleting to follow the style we use in changelog entries (I like it).
I've attempted to condense the amount of output by eliminating redundant
text, re-ordering how things are displayed, and filtering out stuff I
think users won't care about. Likely, this will all require further
refinement, but the ultimate goal is to provide a convenient
dashboard-like view.

Beyond that, this also includes a variety of random fixes and cleanups
discovered during the preparation of this branch. Hopefully nothing
that causes too much distraction. Most notable of these is a fix for
the rather rare situation we're in where the old devel release is no
longer under development, but the next development release hasn't
opened.

To post a comment you must log in.
ppa-dev-tools:tests-dashboard updated
81e5433... by Bryce Harrington

tests: Implement the --architectures option for test result display

Revision history for this message
Athos Ribeiro (athos-ribeiro) wrote :

Hi Bryce! Nice work :)

This LGTM. I added a few inline comments below. As usual, feel free to ignore/act upon them as you please, since they are mostly minor nitpicks.

For large patch sets, it would be nice to have refactoring changes in their own commits.
For instance, bfd3865 is described as a test change/introduction, but also refactors a few code snippets to change a param name. Splitting this in different commits could help reviewers or any future change analysis that may be needed.

review: Approve
Revision history for this message
Bryce Harrington (bryce) wrote :

Noted on the refactoring change. I often squash things together in hopes it makes stuff easier to review, so it's good to know when it doesn't.

With bfd3865, it wasn't just a parameter name change, the type also changed from a string to a list. Normally I would have just pluralized series but since it's the same word singular or plural that would just be confusing, thus the name change. In hindsight, probably better to rename series->release and then to releases.

Thanks again!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/NEWS.md b/NEWS.md
index d134499..533c92f 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,3 +1,10 @@
1# Unreleased #
2
3Autopkgtest action URLs are printed for packages in the PPA when running
4the `ppa tests` command. These can be loaded in a web browser by
5someone with core-dev permissions to start the test runs. `ppa tests`
6can then be re-run to check on the tests status and results.
7
1# 0.2.1 Release #8# 0.2.1 Release #
29
3This corrects some packaging issues when generating .deb and .snap10This corrects some packaging issues when generating .deb and .snap
diff --git a/README.md b/README.md
index d26e1a4..99f10ee 100644
--- a/README.md
+++ b/README.md
@@ -57,3 +57,27 @@ Delete the PPA
57```57```
58$ ppa destroy ppa:my-name/my-ppa58$ ppa destroy ppa:my-name/my-ppa
59```59```
60
61Auto-linked Autopkgtest Trigger URLS
62------------------------------------
63
64By default, `ppa tests` will display autopkgtest triggers as hyperlinked
65text. The hyperlinking feature is supported on many newer terminal
66programs but as it's a relatively recent VTE function it may not yet be
67available in the terminal you use. The following terminal programs are
68believed to support it:
69
70 - iTerm2 3.1
71 - DomTerm 1.0.2
72 - hTerm 1.76
73 - Terminology 1.3?
74 - Gnome Terminal 3.26 (VTE 0.50)
75 - Guake 3.2.1 (VTE 0.50)
76 - TOXTerm 3.5.1 (VTE 0.50)
77 - Tilix 3.26 (VTE 0.50)
78 - Terminator 2.0
79
80This is not a comprehensive list, and likely will lengthen swiftly.
81Meanwhile, if you have a non-supporting browser, the --show-urls option
82can be passed to `ppa tests` to make it display the raw URLs that can be
83manually cut and pasted into your web browser.
diff --git a/ppa/debug.py b/ppa/debug.py
index 009ead4..e6fac49 100644
--- a/ppa/debug.py
+++ b/ppa/debug.py
@@ -10,25 +10,31 @@
1010
11import sys11import sys
12import pprint12import pprint
13import textwrap
1314
14DEBUGGING = False15DEBUGGING = False
1516
1617
17def dbg(msg):18def dbg(msg, wrap=0, prefix=None, indent=''):
18 """Prints information if debugging is enabled"""19 """Prints information if debugging is enabled"""
19 if DEBUGGING:20 if DEBUGGING:
20 if type(msg) is str:21 if type(msg) is str:
21 sys.stderr.write("{}\n".format(msg))22 if wrap == 0 and indent != '':
23 wrap = 72
24 if wrap > 0:
25 if prefix is None and len(indent)>0:
26 prefix = indent
27 msg = textwrap.fill(msg, width=wrap, initial_indent=prefix, subsequent_indent=indent)
28 sys.stderr.write(f"{msg}\n")
22 else:29 else:
23 pprint.pprint(msg)30 pprint.pprint(msg)
2431
2532
26def warn(msg):33def warn(msg):
27 """Prints message to stderr"""34 """Prints warning message to stderr."""
28 sys.stderr.write("Warning: {}\n".format(msg))35 sys.stderr.write(f"Warning: {msg}\n")
2936
3037
31def die(msg, code=1):38def error(msg):
32 """Prints message to stderr and exits with given code"""39 """Prints error message to stderr."""
33 sys.stderr.write("Error: {}\n".format(msg))40 sys.stderr.write(f"Error: {msg}\n")
34 sys.exit(code)
diff --git a/ppa/job.py b/ppa/job.py
index 91f015c..00dd92b 100755
--- a/ppa/job.py
+++ b/ppa/job.py
@@ -78,7 +78,7 @@ class Job:
78 return f"{URL_AUTOPKGTEST}/request.cgi?{rel_str}{arch_str}{pkg_str}{trigger_str}"78 return f"{URL_AUTOPKGTEST}/request.cgi?{rel_str}{arch_str}{pkg_str}{trigger_str}"
7979
8080
81def get_running(response, series=None, ppa=None) -> Iterator[Job]:81def get_running(response, releases=None, ppa=None) -> Iterator[Job]:
82 """Returns iterator currently running autopkgtests for given criteria82 """Returns iterator currently running autopkgtests for given criteria
8383
84 Filters the list of running autopkgtest jobs by the given series84 Filters the list of running autopkgtest jobs by the given series
@@ -88,12 +88,12 @@ def get_running(response, series=None, ppa=None) -> Iterator[Job]:
88 results for that series or ppa.88 results for that series or ppa.
8989
90 :param HTTPResponse response: Context manager; the response from urlopen()90 :param HTTPResponse response: Context manager; the response from urlopen()
91 :param str series: The Ubuntu release codename criteria, or None.91 :param list[str] releases: The Ubuntu series codename(s), or None.
92 :param str ppa: The PPA address criteria, or None.92 :param str ppa: The PPA address criteria, or None.
93 :rtype: Iterator[Job]93 :rtype: Iterator[Job]
94 :returns: Currently running jobs, if any, or an empty list on error94 :returns: Currently running jobs, if any, or an empty list on error
95 """95 """
96 for pkg, jobs in json.loads(response.read().decode('utf-8')).items():96 for pkg, jobs in json.loads(response.read().decode('utf-8') or '{}').items():
97 for handle in jobs:97 for handle in jobs:
98 for codename in jobs[handle]:98 for codename in jobs[handle]:
99 for arch, jobinfo in jobs[handle][codename].items():99 for arch, jobinfo in jobs[handle][codename].items():
@@ -101,12 +101,12 @@ def get_running(response, series=None, ppa=None) -> Iterator[Job]:
101 ppas = jobinfo[0].get('ppas', None)101 ppas = jobinfo[0].get('ppas', None)
102 submit_time = jobinfo[1]102 submit_time = jobinfo[1]
103 job = Job(0, submit_time, pkg, codename, arch, triggers, ppas)103 job = Job(0, submit_time, pkg, codename, arch, triggers, ppas)
104 if (series and (series != job.series)) or (ppa and (ppa not in job.ppas)):104 if (releases and (job.series not in releases)) or (ppa and (ppa not in job.ppas)):
105 continue105 continue
106 yield job106 yield job
107107
108108
109def get_waiting(response, series=None, ppa=None) -> Iterator[Job]:109def get_waiting(response, releases=None, ppa=None) -> Iterator[Job]:
110 """Returns iterator of queued autopkgtests for given criteria110 """Returns iterator of queued autopkgtests for given criteria
111111
112 Filters the list of autopkgtest jobs waiting for execution by the112 Filters the list of autopkgtest jobs waiting for execution by the
@@ -116,12 +116,12 @@ def get_waiting(response, series=None, ppa=None) -> Iterator[Job]:
116 available results for that series or ppa.116 available results for that series or ppa.
117117
118 :param HTTPResponse response: Context manager; the response from urlopen()118 :param HTTPResponse response: Context manager; the response from urlopen()
119 :param str series: The Ubuntu release codename criteria, or None.119 :param list[str] releases: The Ubuntu series codename(s), or None.
120 :param str ppa: The PPA address criteria, or None.120 :param str ppa: The PPA address criteria, or None.
121 :rtype: Iterator[Job]121 :rtype: Iterator[Job]
122 :returns: Currently waiting jobs, if any, or an empty list on error122 :returns: Currently waiting jobs, if any, or an empty list on error
123 """123 """
124 for _, queue in json.loads(response.read().decode('utf-8')).items():124 for _, queue in json.loads(response.read().decode('utf-8') or '{}').items():
125 for codename in queue:125 for codename in queue:
126 for arch in queue[codename]:126 for arch in queue[codename]:
127 n = 0127 n = 0
@@ -134,42 +134,42 @@ def get_waiting(response, series=None, ppa=None) -> Iterator[Job]:
134 triggers = jobinfo.get('triggers', None)134 triggers = jobinfo.get('triggers', None)
135 ppas = jobinfo.get('ppas', None)135 ppas = jobinfo.get('ppas', None)
136 job = Job(n, None, pkg, codename, arch, triggers, ppas)136 job = Job(n, None, pkg, codename, arch, triggers, ppas)
137 if (series and (series != job.series)) or (ppa and (ppa not in job.ppas)):137 if (releases and (job.series not in releases)) or (ppa and (ppa not in job.ppas)):
138 continue138 continue
139 yield job139 yield job
140140
141141
142def show_running(jobs):142def show_running(jobs):
143 """Prints the active (running and waiting) tests"""143 """Prints the active (running and waiting) tests"""
144 rformat = " %-8s %-40s %-8s %-8s %-40s %s"144 rformat = "%-8s %-40s %-8s %-8s %-40s %s"
145145
146 n = 0146 n = 0
147 for n, e in enumerate(jobs, start=1):147 for n, e in enumerate(jobs, start=1):
148 if n == 1:148 if n == 1:
149 print("Running:")149 print("* Running:")
150 ppa_str = ','.join(e.ppas)150 ppa_str = ','.join(e.ppas)
151 trig_str = ','.join(e.triggers)151 trig_str = ','.join(e.triggers)
152 print(rformat % ("time", "pkg", "release", "arch", "ppa", "trigger"))152 print(" # " + rformat % ("time", "pkg", "release", "arch", "ppa", "trigger"))
153 print(rformat % (str(e.submit_time), e.source_package, e.series, e.arch, ppa_str, trig_str))153 print(" - " + rformat % (str(e.submit_time), e.source_package, e.series, e.arch, ppa_str, trig_str))
154 if n == 0:154 if n == 0:
155 print("Running: (none)")155 print("* Running: (none)")
156156
157157
158def show_waiting(jobs):158def show_waiting(jobs):
159 """Prints the active (running and waiting) tests"""159 """Prints the active (running and waiting) tests"""
160 rformat = " %-8s %-40s %-8s %-8s %-40s %s"160 rformat = "%-8s %-40s %-8s %-8s %-40s %s"
161161
162 n = 0162 n = 0
163 for n, e in enumerate(jobs, start=1):163 for n, e in enumerate(jobs, start=1):
164 if n == 1:164 if n == 1:
165 print("Waiting:")165 print("* Waiting:")
166 print(rformat % ("Q-num", "pkg", "release", "arch", "ppa", "trigger"))166 print(" # " + rformat % ("Q-num", "pkg", "release", "arch", "ppa", "trigger"))
167167
168 ppa_str = ','.join(e.ppas)168 ppa_str = ','.join(e.ppas)
169 trig_str = ','.join(e.triggers)169 trig_str = ','.join(e.triggers)
170 print(rformat % (e.number, e.source_package, e.series, e.arch, ppa_str, trig_str))170 print(" - " + rformat % (e.number, e.source_package, e.series, e.arch, ppa_str, trig_str))
171 if n == 0:171 if n == 0:
172 print("Waiting: (none)")172 print("* Waiting: (none)")
173173
174174
175if __name__ == "__main__":175if __name__ == "__main__":
@@ -202,11 +202,11 @@ if __name__ == "__main__":
202202
203 print("running:")203 print("running:")
204 response = urlopen(f"file://{root_dir}/tests/data/running-20220822.json")204 response = urlopen(f"file://{root_dir}/tests/data/running-20220822.json")
205 for job in get_running(response, 'kinetic', ppa):205 for job in get_running(response, ['kinetic'], ppa):
206 print(job)206 print(job)
207 print()207 print()
208208
209 print("waiting:")209 print("waiting:")
210 response = urlopen(f"file://{root_dir}/tests/data/queues-20220822.json")210 response = urlopen(f"file://{root_dir}/tests/data/queues-20220822.json")
211 for job in get_waiting(response, 'kinetic', ppa):211 for job in get_waiting(response, ['kinetic'], ppa):
212 print(job)212 print(job)
diff --git a/ppa/ppa.py b/ppa/ppa.py
index 4140873..7b6f16f 100755
--- a/ppa/ppa.py
+++ b/ppa/ppa.py
@@ -8,6 +8,8 @@
8# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for8# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
9# more information.9# more information.
1010
11"""A wrapper around a Launchpad Personal Package Archive object."""
12
11import re13import re
1214
13from textwrap import indent15from textwrap import indent
@@ -32,7 +34,7 @@ class PpaDoesNotExist(BaseException):
32 """Printable error message.34 """Printable error message.
3335
34 :rtype str:36 :rtype str:
35 :return: Error message about the failure37 :return: Error message about the failure.
36 """38 """
37 if self.message:39 if self.message:
38 return self.message40 return self.message
@@ -40,7 +42,7 @@ class PpaDoesNotExist(BaseException):
4042
4143
42class Ppa:44class Ppa:
43 """Encapsulates data needed to access and conveniently wrap a PPA45 """Encapsulates data needed to access and conveniently wrap a PPA.
4446
45 This object proxies a PPA, allowing lazy initialization and caching47 This object proxies a PPA, allowing lazy initialization and caching
46 of data from the remote.48 of data from the remote.
@@ -88,6 +90,7 @@ class Ppa:
88 return f"{self.team_name}/{self.name}"90 return f"{self.team_name}/{self.name}"
8991
90 @property92 @property
93 @lru_cache
91 def archive(self):94 def archive(self):
92 """Retrieves the LP Archive object from the Launchpad service.95 """Retrieves the LP Archive object from the Launchpad service.
9396
@@ -101,6 +104,15 @@ class Ppa:
101 except NotFound:104 except NotFound:
102 raise PpaDoesNotExist(self.ppa_name, self.team_name)105 raise PpaDoesNotExist(self.ppa_name, self.team_name)
103106
107 @lru_cache
108 def exists(self) -> bool:
109 """Returns true if the PPA exists in Launchpad."""
110 try:
111 self.archive
112 return True
113 except PpaDoesNotExist:
114 return False
115
104 @property116 @property
105 @lru_cache117 @lru_cache
106 def address(self):118 def address(self):
@@ -157,6 +169,7 @@ class Ppa:
157 return retval and self.archive.description == description169 return retval and self.archive.description == description
158170
159 @property171 @property
172 @lru_cache
160 def architectures(self):173 def architectures(self):
161 """Returns the architectures configured to build packages in the PPA.174 """Returns the architectures configured to build packages in the PPA.
162175
@@ -408,7 +421,7 @@ def get_das(distro, series_name, arch_name):
408421
409422
410def get_ppa(lp, config):423def get_ppa(lp, config):
411 """Load the specified PPA from Launchpad424 """Load the specified PPA from Launchpad.
412425
413 :param Lp lp: The Launchpad wrapper object.426 :param Lp lp: The Launchpad wrapper object.
414 :param dict config: Configuration param:value map.427 :param dict config: Configuration param:value map.
diff --git a/ppa/ppa_group.py b/ppa/ppa_group.py
index ce1c0d1..622cf76 100755
--- a/ppa/ppa_group.py
+++ b/ppa/ppa_group.py
@@ -8,12 +8,14 @@
8# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for8# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
9# more information.9# more information.
1010
11from .ppa import Ppa11"""A team or person that owns one or more PPAs in Launchpad."""
12from .text import o2str
1312
14from functools import lru_cache13from functools import lru_cache
15from lazr.restfulclient.errors import BadRequest14from lazr.restfulclient.errors import BadRequest
1615
16from .ppa import Ppa
17from .text import o2str
18
1719
18class PpaAlreadyExists(BaseException):20class PpaAlreadyExists(BaseException):
19 """Exception indicating a PPA operation could not be performed."""21 """Exception indicating a PPA operation could not be performed."""
@@ -28,10 +30,10 @@ class PpaAlreadyExists(BaseException):
28 self.message = message30 self.message = message
2931
30 def __str__(self):32 def __str__(self):
31 """Printable error message33 """Printable error message.
3234
33 :rtype str:35 :rtype str:
34 :return: Error message about the failure36 :return: Error message about the failure.
35 """37 """
36 if self.message:38 if self.message:
37 return self.message39 return self.message
@@ -51,7 +53,11 @@ class PpaGroup:
51 :param launchpadlib.service service: The Launchpad service object.53 :param launchpadlib.service service: The Launchpad service object.
52 :param str name: Launchpad username or team name.54 :param str name: Launchpad username or team name.
53 """55 """
54 assert service is not None56 if not service:
57 raise ValueError("undefined service.")
58 if not name:
59 raise ValueError("undefined name.")
60
55 self.service = service61 self.service = service
56 self.name = name62 self.name = name
5763
@@ -116,9 +122,10 @@ class PpaGroup:
116 @property122 @property
117 @lru_cache123 @lru_cache
118 def ppas(self):124 def ppas(self):
119 """Generator to access the PPAs in this group125 """Generator to access the PPAs in this group.
126
120 :rtype: Iterator[ppa.Ppa]127 :rtype: Iterator[ppa.Ppa]
121 :returns: Each PPA in the group as a ppa.Ppa object128 :returns: Each PPA in the group as a ppa.Ppa object.
122 """129 """
123 for lp_ppa in self.team.ppas:130 for lp_ppa in self.team.ppas:
124 if '-deletedppa' in lp_ppa.name:131 if '-deletedppa' in lp_ppa.name:
@@ -129,6 +136,7 @@ class PpaGroup:
129 @lru_cache136 @lru_cache
130 def get(self, ppa_name):137 def get(self, ppa_name):
131 """Provides a Ppa for the named ppa.138 """Provides a Ppa for the named ppa.
139
132 :rtype: ppa.Ppa140 :rtype: ppa.Ppa
133 :returns: A Ppa object describing the named ppa.141 :returns: A Ppa object describing the named ppa.
134 """142 """
diff --git a/ppa/result.py b/ppa/result.py
index 012165d..db0af48 100755
--- a/ppa/result.py
+++ b/ppa/result.py
@@ -13,7 +13,7 @@
1313
14import re14import re
15import urllib.request15import urllib.request
16from functools import lru_cache, cached_property16from functools import lru_cache
17from typing import List, Iterator17from typing import List, Iterator
18import gzip18import gzip
19import time19import time
@@ -65,14 +65,16 @@ class Result:
65 :rtype: str65 :rtype: str
66 :returns: Printable summary of the object.66 :returns: Printable summary of the object.
67 """67 """
68 return f"{self.source} on {self.series} for {self.arch} @ {self.timestamp}"68 pad = ' ' * (1 + abs(len('ppc64el') - len(self.arch)))
69 return f"{self.source} on {self.series} for {self.arch}{pad}@ {self.timestamp}"
6970
70 @property71 @property
71 def timestamp(self) -> str:72 def timestamp(self) -> str:
72 """Formats the result's completion time as a string."""73 """Formats the result's completion time as a string."""
73 return time.strftime("%d.%m.%y %H:%M:%S", self.time)74 return time.strftime("%d.%m.%y %H:%M:%S", self.time)
7475
75 @cached_property76 @property
77 @lru_cache
76 def log(self) -> str:78 def log(self) -> str:
77 """Returns log contents for results, downloading if necessary.79 """Returns log contents for results, downloading if necessary.
7880
@@ -103,7 +105,8 @@ class Result:
103 return None105 return None
104106
105 # TODO: Merge triggers and get_triggers()107 # TODO: Merge triggers and get_triggers()
106 @cached_property108 @property
109 @lru_cache
107 def triggers(self) -> List[str]:110 def triggers(self) -> List[str]:
108 """Returns package/version parameters used to generate this Result.111 """Returns package/version parameters used to generate this Result.
109112
@@ -165,7 +168,8 @@ class Result:
165 subtests.append(Subtest(line))168 subtests.append(Subtest(line))
166 return subtests169 return subtests
167170
168 @cached_property171 @property
172 @lru_cache
169 def status(self) -> str:173 def status(self) -> str:
170 """Returns overall status of all subtests174 """Returns overall status of all subtests
171175
@@ -187,7 +191,8 @@ class Result:
187 return 'FAIL'191 return 'FAIL'
188 return 'PASS'192 return 'PASS'
189193
190 @cached_property194 @property
195 @lru_cache
191 def status_icon(self) -> str:196 def status_icon(self) -> str:
192 """Unicode symbol corresponding to test's overall status.197 """Unicode symbol corresponding to test's overall status.
193198
diff --git a/ppa/subtest.py b/ppa/subtest.py
index 2f18190..8d871c4 100755
--- a/ppa/subtest.py
+++ b/ppa/subtest.py
@@ -11,7 +11,7 @@
1111
12"""An individual DEP8 test run"""12"""An individual DEP8 test run"""
1313
14from functools import cached_property14from functools import lru_cache
1515
1616
17class Subtest:17class Subtest:
@@ -55,7 +55,8 @@ class Subtest:
55 """55 """
56 return f"{self.desc:25} {self.status:6} {self.status_icon}"56 return f"{self.desc:25} {self.status:6} {self.status_icon}"
5757
58 @cached_property58 @property
59 @lru_cache
59 def desc(self) -> str:60 def desc(self) -> str:
60 """The descriptive text for the given subtest.61 """The descriptive text for the given subtest.
6162
@@ -64,7 +65,8 @@ class Subtest:
64 """65 """
65 return next(iter(self._line.split()), '')66 return next(iter(self._line.split()), '')
6667
67 @cached_property68 @property
69 @lru_cache
68 def status(self) -> str:70 def status(self) -> str:
69 """The success or failure of the given subtest.71 """The success or failure of the given subtest.
7072
@@ -76,7 +78,8 @@ class Subtest:
76 return k78 return k
77 return 'UNKNOWN'79 return 'UNKNOWN'
7880
79 @cached_property81 @property
82 @lru_cache
80 def status_icon(self) -> str:83 def status_icon(self) -> str:
81 """Unicode symbol corresponding to subtest's status.84 """Unicode symbol corresponding to subtest's status.
8285
diff --git a/ppa/text.py b/ppa/text.py
index b1e1453..ebd3ec3 100644
--- a/ppa/text.py
+++ b/ppa/text.py
@@ -69,8 +69,8 @@ def o2str(obj):
6969
70@lru_cache70@lru_cache
71def to_bool(value):71def to_bool(value):
72 """72 """Converts 'something' to boolean. Raises exception for invalid formats.
73 Converts 'something' to boolean. Raises exception for invalid formats73
74 Possible True values: 1, True, '1', 'TRue', 'yes', 'y', 't'74 Possible True values: 1, True, '1', 'TRue', 'yes', 'y', 't'
75 Possible False values: 0, False, None, [], {}, '', '0', 'faLse', 'no', 'n', 'f', 0.075 Possible False values: 0, False, None, [], {}, '', '0', 'faLse', 'no', 'n', 'f', 0.0
76 """76 """
@@ -116,31 +116,16 @@ def o2float(value):
116 raise116 raise
117117
118118
119def ansi_hyperlink(url, text):
120 """Formats text into a hyperlink using ANSI escape codes.
121
122 :param str url: The linked action to load in a web browser.
123 :param str text: The visible text to show.
124 :rtype: str
125 :returns: ANSI escape code sequence to display the hyperlink.
126 """
127 return f"\u001b]8;;{url}\u001b\\{text}\u001b]8;;\u001b\\"
128
129
119if __name__ == "__main__":130if __name__ == "__main__":
120 test_cases = [131 print(ansi_hyperlink("https://launchpad.net/ppa-dev-tools", "ppa-dev-tools"))
121 ('true', True),
122 ('t', True),
123 ('yes', True),
124 ('y', True),
125 ('1', True),
126 ('false', False),
127 ('f', False),
128 ('no', False),
129 ('n', False),
130 ('0', False),
131 ('', False),
132 (1, True),
133 (0, False),
134 (1.0, True),
135 (0.0, False),
136 ([], False),
137 ({}, False),
138 ((), False),
139 ([1], True),
140 ({1: 2}, True),
141 ((1,), True),
142 (None, False),
143 (object(), True),
144 ]
145 for test, expected in test_cases:
146 assert to_bool(test) == expected, f"to_bool({test}) failed to return {expected}"
diff --git a/ppa/trigger.py b/ppa/trigger.py
index ae00b44..72f8ba6 100755
--- a/ppa/trigger.py
+++ b/ppa/trigger.py
@@ -11,7 +11,7 @@
1111
12"""A directive to run a DEP8 test against a source package"""12"""A directive to run a DEP8 test against a source package"""
1313
14from functools import cached_property14from functools import lru_cache
15from urllib.parse import urlencode15from urllib.parse import urlencode
1616
17from .constants import URL_AUTOPKGTEST17from .constants import URL_AUTOPKGTEST
@@ -60,7 +60,8 @@ class Trigger:
60 """60 """
61 return f"{self.package}/{self.version}"61 return f"{self.package}/{self.version}"
6262
63 @cached_property63 @property
64 @lru_cache
64 def history_url(self) -> str:65 def history_url(self) -> str:
65 """Renders the trigger as a URL to the job history.66 """Renders the trigger as a URL to the job history.
6667
@@ -74,7 +75,8 @@ class Trigger:
74 pkg_str = f"{prefix}/{self.package}"75 pkg_str = f"{prefix}/{self.package}"
75 return f"{URL_AUTOPKGTEST}/packages/{pkg_str}/{self.series}/{self.arch}"76 return f"{URL_AUTOPKGTEST}/packages/{pkg_str}/{self.series}/{self.arch}"
7677
77 @cached_property78 @property
79 @lru_cache
78 def action_url(self) -> str:80 def action_url(self) -> str:
79 """Renders the trigger as a URL to start running the test.81 """Renders the trigger as a URL to start running the test.
8082
diff --git a/scripts/ppa b/scripts/ppa
index 04abd4c..09ba1e7 100755
--- a/scripts/ppa
+++ b/scripts/ppa
@@ -51,7 +51,7 @@ import sys
51import time51import time
52import argparse52import argparse
53from inspect import currentframe53from inspect import currentframe
54from distro_info import UbuntuDistroInfo54from distro_info import UbuntuDistroInfo, DistroDataOutdated
5555
56try:56try:
57 from ruamel import yaml57 from ruamel import yaml
@@ -88,11 +88,11 @@ from ppa.result import (
88 Result,88 Result,
89 get_results89 get_results
90)90)
91from ppa.text import o2str91from ppa.text import o2str, ansi_hyperlink
92from ppa.trigger import Trigger92from ppa.trigger import Trigger
9393
94import ppa.debug94import ppa.debug
95from ppa.debug import dbg, warn95from ppa.debug import dbg, warn, error
9696
9797
98def UNIMPLEMENTED():98def UNIMPLEMENTED():
@@ -192,6 +192,18 @@ def create_arg_parser():
192 tests_parser.add_argument('ppa_name', metavar='ppa-name',192 tests_parser.add_argument('ppa_name', metavar='ppa-name',
193 action='store',193 action='store',
194 help="Name of the PPA to view tests")194 help="Name of the PPA to view tests")
195 tests_parser.add_argument('-a', '--arches', '--arch', '--architectures',
196 dest="architectures", action='store',
197 help="Comma-separated list of hardware architectures to include in triggers")
198 tests_parser.add_argument('-r', '--releases', '--release',
199 dest="releases", action='store',
200 help="Comma-separated list of Ubuntu release codenames to show triggers for")
201 tests_parser.add_argument('-p', '--packages', '--package',
202 dest="packages", action='store',
203 help="Comma-separated list of source package names to show triggers for")
204 tests_parser.add_argument('-L', '--show-urls',
205 dest='show_urls', action='store_true',
206 help="Display unformatted trigger action URLs")
195207
196 # Wait Command208 # Wait Command
197 wait_parser = subparser.add_parser('wait', help='wait help')209 wait_parser = subparser.add_parser('wait', help='wait help')
@@ -260,6 +272,13 @@ def create_config(lp, args):
260 else:272 else:
261 warn(f"Invalid architectures '{args.architectures}'")273 warn(f"Invalid architectures '{args.architectures}'")
262 return None274 return None
275 elif args.command == 'tests':
276 if args.releases is not None:
277 if args.releases:
278 config['releases'] = args.releases.split(',')
279 else:
280 warn(f"Invalid releases '{args.releases}'")
281 return None
263282
264 return config283 return config
265284
@@ -550,49 +569,88 @@ def command_tests(lp, config):
550 if not lp:569 if not lp:
551 return 1570 return 1
552571
553 # Show tests only from the current development release572 releases = config.get('releases', None)
554 udi = UbuntuDistroInfo()573 if releases is None:
555 release = udi.devel()574 udi = UbuntuDistroInfo()
575 try:
576 # Show tests only from the current development release
577 releases = [ udi.devel() ]
578 except DistroDataOutdated as e:
579 # If no development release defined, use the current active release
580 warn(f"Devel release is undefined; assuming stable release instead.")
581 dbg(f"({e})", wrap=72, prefix=' - ', indent=' ')
582 releases = [ udi.stable() ]
583
584 packages = config.get('packages', None)
585
586 ppa = get_ppa(lp, config)
587 if not ppa.exists():
588 error(f"PPA {ppa.name} does not exist for user {ppa.team_name}")
589 return 1
590
591 architectures = config.get('architectures', ARCHES_AUTOPKGTEST)
556592
557 try:593 try:
558 ppa = get_ppa(lp, config)594 # Triggers
595 print("* Triggers:")
559 for source_pub in ppa.get_source_publications():596 for source_pub in ppa.get_source_publications():
560 # Triggers597 series = source_pub.distro_series.name
561 for arch in ARCHES_AUTOPKGTEST:598 if series not in releases:
562 trigger = Trigger(599 continue
563 package=source_pub.source_package_name,600 pkg = source_pub.source_package_name
564 version=source_pub.source_package_version,601 if packages and (pkg not in packages):
565 arch=arch,602 continue
566 series=source_pub.distro_series.name,603 ver = source_pub.source_package_version
567 ppa=ppa)604 url = f"https://launchpad.net/ubuntu/+source/{pkg}/{ver}"
568 print(trigger.action_url)605 source_hyperlink = ansi_hyperlink(url, f"{pkg}/{ver}")
606 print(f" - Source {source_hyperlink}: {source_pub.status}")
607 triggers = [Trigger(pkg, ver, arch, series, ppa) for arch in architectures]
608
609 if config.get("show_urls"):
610 for trigger in triggers:
611 print(f" + {trigger.arch}: {trigger.action_url}♻️ ")
612 for trigger in triggers:
613 print(f" + {trigger.arch}: {trigger.action_url}💍")
614
615 else:
616 for trigger in triggers:
617 pad = ' ' * (1 + abs(len('ppc64el') - len(trigger.arch)))
618 basic_trig = ansi_hyperlink(trigger.action_url, f"Trigger basic @{trigger.arch}♻️ ")
619 all_proposed_trig = ansi_hyperlink(trigger.action_url + "&all-proposed=1",
620 f"Trigger all-proposed @{trigger.arch}💍")
621 print(f" + " + pad.join([basic_trig, all_proposed_trig]))
569622
570 # Results623 # Results
571 base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"624 print("* Results:")
572 base_results_url = base_results_fmt % (release, ppa.team_name, ppa.name)625 for release in releases:
573 url = f"{base_results_url}?format=plain"626 base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"
574 response = open_url(url)627 base_results_url = base_results_fmt % (release, ppa.team_name, ppa.name)
575 if response:628 url = f"{base_results_url}?format=plain"
576 for result in get_results(response, base_results_url, arches=ARCHES_AUTOPKGTEST):629 response = open_url(url)
577 print(f"* {result} {result.status_icon}")630 if response:
578 print(f" - Triggers: " + ', '.join([str(r) for r in result.get_triggers()]))631 trigger_sets = {}
579 if result.status != 'PASS':632 for result in get_results(response, base_results_url, arches=architectures):
580 print(f" - Status: {result.status}")633 trigger = ', '.join([str(r) for r in result.get_triggers()])
581 print(f" - Log: {result.url}")634 trigger_sets.setdefault(trigger, '')
582 for subtest in result.get_subtests():635 trigger_sets[trigger] += f" + {result.status_icon} {result}\n"
583 print(f" - {subtest}")636 if result.status != 'PASS':
584 print()637 trigger_sets[trigger] += f" • Status: {result.status}\n"
638 trigger_sets[trigger] += f" • Log: {result.url}\n"
639 for subtest in result.get_subtests():
640 trigger_sets[trigger] += f" • {subtest}\n"
641 for trigger, result in trigger_sets.items():
642 print(f" - {trigger}\n{result}")
585643
586 # Running Queue644 # Running Queue
587 response = open_url(f"{URL_AUTOPKGTEST}/static/running.json", "running autopkgtests")645 response = open_url(f"{URL_AUTOPKGTEST}/static/running.json", "running autopkgtests")
588 if response:646 if response:
589 show_running(sorted(get_running(response, series=release, ppa=str(ppa)),647 show_running(sorted(get_running(response, releases=releases, ppa=str(ppa)),
590 key=lambda k: str(k.submit_time)))648 key=lambda k: str(k.submit_time)))
591649
592 # Waiting Queue650 # Waiting Queue
593 response = open_url(f"{URL_AUTOPKGTEST}/queues.json", "waiting autopkgtests")651 response = open_url(f"{URL_AUTOPKGTEST}/queues.json", "waiting autopkgtests")
594 if response:652 if response:
595 show_waiting(get_waiting(response, series=release, ppa=str(ppa)))653 show_waiting(get_waiting(response, releases=releases, ppa=str(ppa)))
596654
597 return os.EX_OK655 return os.EX_OK
598 except KeyboardInterrupt:656 except KeyboardInterrupt:
@@ -640,7 +698,7 @@ def main(args):
640 return func(lp, config, param)698 return func(lp, config, param)
641 return func(lp, config)699 return func(lp, config)
642 except KeyError:700 except KeyError:
643 parser.error("No such command {}".format(args.command))701 parser.error(f"No such command {args.command}")
644 return 1702 return 1
645703
646704
diff --git a/tests/test_job.py b/tests/test_job.py
index 78a12c7..1d1708b 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -81,7 +81,7 @@ def test_get_running():
81 '"Log Output Here"'81 '"Log Output Here"'
82 '] } } } }')82 '] } } } }')
83 fake_response = RequestResponseMock(json_text)83 fake_response = RequestResponseMock(json_text)
84 job = next(get_running(fake_response, series='focal', ppa='ppa:me/myppa'))84 job = next(get_running(fake_response, releases=['focal'], ppa='ppa:me/myppa'))
85 assert repr(job) == "Job(source_package='mypackage', series='focal', arch='arm64')"85 assert repr(job) == "Job(source_package='mypackage', series='focal', arch='arm64')"
86 assert job.triggers == ["yourpackage/1.2.3"]86 assert job.triggers == ["yourpackage/1.2.3"]
87 assert job.ppas == ["ppa:me/myppa"]87 assert job.ppas == ["ppa:me/myppa"]
@@ -100,7 +100,7 @@ def test_get_waiting():
100 ' \\"triggers\\": [ \\"c/3.2-1\\", \\"d/2-2\\" ] }"'100 ' \\"triggers\\": [ \\"c/3.2-1\\", \\"d/2-2\\" ] }"'
101 '] } } }')101 '] } } }')
102 fake_response = RequestResponseMock(json_text)102 fake_response = RequestResponseMock(json_text)
103 job = next(get_waiting(fake_response, series='focal', ppa='ppa:me/myppa'))103 job = next(get_waiting(fake_response, releases=['focal'], ppa='ppa:me/myppa'))
104 assert job104 assert job
105 assert job.source_package == "b"105 assert job.source_package == "b"
106 assert job.ppas == ['ppa:me/myppa']106 assert job.ppas == ['ppa:me/myppa']
diff --git a/tests/test_ppa_group.py b/tests/test_ppa_group.py
index 63907fd..f8aa8db 100644
--- a/tests/test_ppa_group.py
+++ b/tests/test_ppa_group.py
@@ -23,9 +23,14 @@ from tests.helpers import PersonMock, LaunchpadMock, LpServiceMock
2323
24def test_object():24def test_object():
25 """Checks that PpaGroup objects can be instantiated."""25 """Checks that PpaGroup objects can be instantiated."""
26 ppa_group = PpaGroup(service=LpServiceMock(), name=None)26 ppa_group = PpaGroup(service=LpServiceMock(), name='test-ppa')
27 assert ppa_group27 assert ppa_group
2828
29 with pytest.raises(ValueError):
30 ppa_group = PpaGroup(service=LpServiceMock(), name=None)
31 with pytest.raises(ValueError):
32 ppa_group = PpaGroup(service=None, name='test-ppa')
33
2934
30def test_create_ppa():35def test_create_ppa():
31 """Checks that PpaGroups can create PPAs."""36 """Checks that PpaGroups can create PPAs."""
diff --git a/tests/test_result.py b/tests/test_result.py
index 8c498a4..d928efb 100644
--- a/tests/test_result.py
+++ b/tests/test_result.py
@@ -41,7 +41,7 @@ def test_str():
41 """Checks Result object textual presentation."""41 """Checks Result object textual presentation."""
42 timestamp = time.strptime('20030201_040506', "%Y%m%d_%H%M%S")42 timestamp = time.strptime('20030201_040506', "%Y%m%d_%H%M%S")
43 result = Result('url', timestamp, 'b', 'c', 'd')43 result = Result('url', timestamp, 'b', 'c', 'd')
44 assert f"{result}" == 'd on b for c @ 01.02.03 04:05:06'44 assert f"{result}" == 'd on b for c @ 01.02.03 04:05:06'
4545
4646
47def test_timestamp():47def test_timestamp():
diff --git a/tests/test_scripts_ppa.py b/tests/test_scripts_ppa.py
index 812b4b5..51a0755 100644
--- a/tests/test_scripts_ppa.py
+++ b/tests/test_scripts_ppa.py
@@ -112,7 +112,7 @@ def test_create_arg_parser():
112 args.debug = None112 args.debug = None
113113
114 # Check -q, --dry-run114 # Check -q, --dry-run
115 args = parser.parse_args(['cmd', '--dry-run'])115 args = parser.parse_args(['--dry-run', 'status', 'test-ppa'])
116 assert args.dry_run == True116 assert args.dry_run == True
117 args.dry_run = None117 args.dry_run = None
118118
@@ -159,11 +159,18 @@ def test_create_arg_parser_create():
159 args = parser.parse_args(['create', 'test-ppa'])159 args = parser.parse_args(['create', 'test-ppa'])
160 assert args.ppa_name == 'test-ppa'160 assert args.ppa_name == 'test-ppa'
161161
162 # Check -a, --arch, --arches, --architectures162 # Check that command args can come before or after the ppa name
163 args = parser.parse_args(['create', 'test-ppa', '-a', 'x'])163 args = parser.parse_args(['create', 'test-ppa', '-a', 'x'])
164 assert args.architectures == 'x'164 assert args.architectures == 'x'
165 args.architectures = None165 args.architectures = None
166 args = parser.parse_args(['create', '-a', 'x', 'test-ppa'])
167 assert args.architectures == 'x'
168 args.architectures = None
166169
170 # Check -a, --arch, --arches, --architectures
171 args = parser.parse_args(['create', 'test-ppa', '-a', 'x'])
172 assert args.architectures == 'x'
173 args.architectures = None
167 args = parser.parse_args(['create', 'test-ppa', '--arch', 'x'])174 args = parser.parse_args(['create', 'test-ppa', '--arch', 'x'])
168 assert args.architectures == 'x'175 assert args.architectures == 'x'
169 args.architectures = None176 args.architectures = None
@@ -175,6 +182,72 @@ def test_create_arg_parser_create():
175 args.architectures = None182 args.architectures = None
176183
177184
185def test_create_arg_parser_tests():
186 """Checks argument parsing for the 'tests' command."""
187 parser = script.create_arg_parser()
188
189 # Check ppa_name
190 args = parser.parse_args(['tests', 'test-ppa'])
191 assert args.ppa_name == 'test-ppa'
192
193 # Check -a, --arch, --arches, --architectures
194 args = parser.parse_args(['tests', 'test-ppa', '-a', 'x'])
195 assert args.architectures == 'x'
196 args.architectures = None
197
198 args = parser.parse_args(['tests', 'test-ppa', '--arch', 'x'])
199 assert args.architectures == 'x'
200 args.architectures = None
201 args = parser.parse_args(['tests', 'test-ppa', '--arches', 'x'])
202 assert args.architectures == 'x'
203 args.architectures = None
204 args = parser.parse_args(['tests', 'test-ppa', '--architectures', 'a,b,c'])
205 assert args.architectures == 'a,b,c'
206 args.architectures = None
207
208 # Check -r, --release, --releases
209 args = parser.parse_args(['tests', 'test-ppa', '-r', 'x'])
210 assert args.releases == 'x'
211 args.releases = None
212 args = parser.parse_args(['tests', 'test-ppa', '--release', 'x'])
213 assert args.releases == 'x'
214 args.releases = None
215 args = parser.parse_args(['tests', 'test-ppa', '--releases', 'x'])
216 assert args.releases == 'x'
217 args.releases = None
218 args = parser.parse_args(['tests', 'test-ppa', '--releases', 'x,y,z'])
219 assert args.releases == 'x,y,z'
220 args.releases = None
221
222 # Check -p, --package, --packages
223 args = parser.parse_args(['tests', 'tests-ppa', '-p', 'x'])
224 assert args.packages == 'x'
225 args.packages = None
226 args = parser.parse_args(['tests', 'tests-ppa', '--package', 'x'])
227 assert args.packages == 'x'
228 args.packages = None
229 args = parser.parse_args(['tests', 'tests-ppa', '--packages', 'x'])
230 assert args.packages == 'x'
231 args.packages = None
232 args = parser.parse_args(['tests', 'tests-ppa', '--packages', 'x,y,z'])
233 assert args.packages == 'x,y,z'
234 args.packages = None
235
236 # Check --show-urls, --show-url, -L
237 args = parser.parse_args(['tests', 'tests-ppa'])
238 assert args.show_urls == False
239 args.show_urls = None
240 args = parser.parse_args(['tests', 'tests-ppa', '--show-urls'])
241 assert args.show_urls == True
242 args.show_urls = None
243 args = parser.parse_args(['tests', 'tests-ppa', '--show-url'])
244 assert args.show_urls == True
245 args.show_urls = None
246 args = parser.parse_args(['tests', 'tests-ppa', '-L'])
247 assert args.show_urls == True
248 args.show_urls = None
249
250
178@pytest.mark.xfail(reason="Unimplemented")251@pytest.mark.xfail(reason="Unimplemented")
179def test_create_config():252def test_create_config():
180 # args = []253 # args = []
diff --git a/tests/test_text.py b/tests/test_text.py
181new file mode 100644254new file mode 100644
index 0000000..57ce979
--- /dev/null
+++ b/tests/test_text.py
@@ -0,0 +1,54 @@
1#!/usr/bin/env python3
2# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
3
4# Author: Bryce Harrington <bryce@canonical.com>
5#
6# Copyright (C) 2022 Bryce W. Harrington
7#
8# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
9# more information.
10
11import os
12import sys
13
14import pytest
15
16sys.path.insert(0, os.path.realpath(
17 os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")))
18
19import ppa.text
20
21@pytest.mark.parametrize('input, expected', [
22 ('true', True),
23 ('t', True),
24 ('yes', True),
25 ('y', True),
26 ('1', True),
27 ('false', False),
28 ('f', False),
29 ('no', False),
30 ('n', False),
31 ('0', False),
32 ('', False),
33 (1, True),
34 (0, False),
35 (1.0, True),
36 (0.0, False),
37 ((), False),
38 ((1,), True),
39 (None, False),
40 (object(), True),
41])
42def test_to_bool(input, expected):
43 """Check that the given input produces the expected true/false result.
44
45 :param * input: Any available type to be converted to boolean.
46 :param bool expected: The True or False result to expect.
47 """
48 assert ppa.text.to_bool(input) == expected
49
50
51def test_ansi_hyperlink():
52 """Check that text can be linked with a url."""
53 assert ppa.text.ansi_hyperlink("xxx", "yyy") == "\u001b]8;;xxx\u001b\\yyy\u001b]8;;\u001b\\"
54

Subscribers

People subscribed via source and target branches

to all changes: