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
1diff --git a/NEWS.md b/NEWS.md
2index d134499..533c92f 100644
3--- a/NEWS.md
4+++ b/NEWS.md
5@@ -1,3 +1,10 @@
6+# Unreleased #
7+
8+Autopkgtest action URLs are printed for packages in the PPA when running
9+the `ppa tests` command. These can be loaded in a web browser by
10+someone with core-dev permissions to start the test runs. `ppa tests`
11+can then be re-run to check on the tests status and results.
12+
13 # 0.2.1 Release #
14
15 This corrects some packaging issues when generating .deb and .snap
16diff --git a/README.md b/README.md
17index d26e1a4..99f10ee 100644
18--- a/README.md
19+++ b/README.md
20@@ -57,3 +57,27 @@ Delete the PPA
21 ```
22 $ ppa destroy ppa:my-name/my-ppa
23 ```
24+
25+Auto-linked Autopkgtest Trigger URLS
26+------------------------------------
27+
28+By default, `ppa tests` will display autopkgtest triggers as hyperlinked
29+text. The hyperlinking feature is supported on many newer terminal
30+programs but as it's a relatively recent VTE function it may not yet be
31+available in the terminal you use. The following terminal programs are
32+believed to support it:
33+
34+ - iTerm2 3.1
35+ - DomTerm 1.0.2
36+ - hTerm 1.76
37+ - Terminology 1.3?
38+ - Gnome Terminal 3.26 (VTE 0.50)
39+ - Guake 3.2.1 (VTE 0.50)
40+ - TOXTerm 3.5.1 (VTE 0.50)
41+ - Tilix 3.26 (VTE 0.50)
42+ - Terminator 2.0
43+
44+This is not a comprehensive list, and likely will lengthen swiftly.
45+Meanwhile, if you have a non-supporting browser, the --show-urls option
46+can be passed to `ppa tests` to make it display the raw URLs that can be
47+manually cut and pasted into your web browser.
48diff --git a/ppa/debug.py b/ppa/debug.py
49index 009ead4..e6fac49 100644
50--- a/ppa/debug.py
51+++ b/ppa/debug.py
52@@ -10,25 +10,31 @@
53
54 import sys
55 import pprint
56+import textwrap
57
58 DEBUGGING = False
59
60
61-def dbg(msg):
62+def dbg(msg, wrap=0, prefix=None, indent=''):
63 """Prints information if debugging is enabled"""
64 if DEBUGGING:
65 if type(msg) is str:
66- sys.stderr.write("{}\n".format(msg))
67+ if wrap == 0 and indent != '':
68+ wrap = 72
69+ if wrap > 0:
70+ if prefix is None and len(indent)>0:
71+ prefix = indent
72+ msg = textwrap.fill(msg, width=wrap, initial_indent=prefix, subsequent_indent=indent)
73+ sys.stderr.write(f"{msg}\n")
74 else:
75 pprint.pprint(msg)
76
77
78 def warn(msg):
79- """Prints message to stderr"""
80- sys.stderr.write("Warning: {}\n".format(msg))
81+ """Prints warning message to stderr."""
82+ sys.stderr.write(f"Warning: {msg}\n")
83
84
85-def die(msg, code=1):
86- """Prints message to stderr and exits with given code"""
87- sys.stderr.write("Error: {}\n".format(msg))
88- sys.exit(code)
89+def error(msg):
90+ """Prints error message to stderr."""
91+ sys.stderr.write(f"Error: {msg}\n")
92diff --git a/ppa/job.py b/ppa/job.py
93index 91f015c..00dd92b 100755
94--- a/ppa/job.py
95+++ b/ppa/job.py
96@@ -78,7 +78,7 @@ class Job:
97 return f"{URL_AUTOPKGTEST}/request.cgi?{rel_str}{arch_str}{pkg_str}{trigger_str}"
98
99
100-def get_running(response, series=None, ppa=None) -> Iterator[Job]:
101+def get_running(response, releases=None, ppa=None) -> Iterator[Job]:
102 """Returns iterator currently running autopkgtests for given criteria
103
104 Filters the list of running autopkgtest jobs by the given series
105@@ -88,12 +88,12 @@ def get_running(response, series=None, ppa=None) -> Iterator[Job]:
106 results for that series or ppa.
107
108 :param HTTPResponse response: Context manager; the response from urlopen()
109- :param str series: The Ubuntu release codename criteria, or None.
110+ :param list[str] releases: The Ubuntu series codename(s), or None.
111 :param str ppa: The PPA address criteria, or None.
112 :rtype: Iterator[Job]
113 :returns: Currently running jobs, if any, or an empty list on error
114 """
115- for pkg, jobs in json.loads(response.read().decode('utf-8')).items():
116+ for pkg, jobs in json.loads(response.read().decode('utf-8') or '{}').items():
117 for handle in jobs:
118 for codename in jobs[handle]:
119 for arch, jobinfo in jobs[handle][codename].items():
120@@ -101,12 +101,12 @@ def get_running(response, series=None, ppa=None) -> Iterator[Job]:
121 ppas = jobinfo[0].get('ppas', None)
122 submit_time = jobinfo[1]
123 job = Job(0, submit_time, pkg, codename, arch, triggers, ppas)
124- if (series and (series != job.series)) or (ppa and (ppa not in job.ppas)):
125+ if (releases and (job.series not in releases)) or (ppa and (ppa not in job.ppas)):
126 continue
127 yield job
128
129
130-def get_waiting(response, series=None, ppa=None) -> Iterator[Job]:
131+def get_waiting(response, releases=None, ppa=None) -> Iterator[Job]:
132 """Returns iterator of queued autopkgtests for given criteria
133
134 Filters the list of autopkgtest jobs waiting for execution by the
135@@ -116,12 +116,12 @@ def get_waiting(response, series=None, ppa=None) -> Iterator[Job]:
136 available results for that series or ppa.
137
138 :param HTTPResponse response: Context manager; the response from urlopen()
139- :param str series: The Ubuntu release codename criteria, or None.
140+ :param list[str] releases: The Ubuntu series codename(s), or None.
141 :param str ppa: The PPA address criteria, or None.
142 :rtype: Iterator[Job]
143 :returns: Currently waiting jobs, if any, or an empty list on error
144 """
145- for _, queue in json.loads(response.read().decode('utf-8')).items():
146+ for _, queue in json.loads(response.read().decode('utf-8') or '{}').items():
147 for codename in queue:
148 for arch in queue[codename]:
149 n = 0
150@@ -134,42 +134,42 @@ def get_waiting(response, series=None, ppa=None) -> Iterator[Job]:
151 triggers = jobinfo.get('triggers', None)
152 ppas = jobinfo.get('ppas', None)
153 job = Job(n, None, pkg, codename, arch, triggers, ppas)
154- if (series and (series != job.series)) or (ppa and (ppa not in job.ppas)):
155+ if (releases and (job.series not in releases)) or (ppa and (ppa not in job.ppas)):
156 continue
157 yield job
158
159
160 def show_running(jobs):
161 """Prints the active (running and waiting) tests"""
162- rformat = " %-8s %-40s %-8s %-8s %-40s %s"
163+ rformat = "%-8s %-40s %-8s %-8s %-40s %s"
164
165 n = 0
166 for n, e in enumerate(jobs, start=1):
167 if n == 1:
168- print("Running:")
169+ print("* Running:")
170 ppa_str = ','.join(e.ppas)
171 trig_str = ','.join(e.triggers)
172- print(rformat % ("time", "pkg", "release", "arch", "ppa", "trigger"))
173- print(rformat % (str(e.submit_time), e.source_package, e.series, e.arch, ppa_str, trig_str))
174+ print(" # " + rformat % ("time", "pkg", "release", "arch", "ppa", "trigger"))
175+ print(" - " + rformat % (str(e.submit_time), e.source_package, e.series, e.arch, ppa_str, trig_str))
176 if n == 0:
177- print("Running: (none)")
178+ print("* Running: (none)")
179
180
181 def show_waiting(jobs):
182 """Prints the active (running and waiting) tests"""
183- rformat = " %-8s %-40s %-8s %-8s %-40s %s"
184+ rformat = "%-8s %-40s %-8s %-8s %-40s %s"
185
186 n = 0
187 for n, e in enumerate(jobs, start=1):
188 if n == 1:
189- print("Waiting:")
190- print(rformat % ("Q-num", "pkg", "release", "arch", "ppa", "trigger"))
191+ print("* Waiting:")
192+ print(" # " + rformat % ("Q-num", "pkg", "release", "arch", "ppa", "trigger"))
193
194 ppa_str = ','.join(e.ppas)
195 trig_str = ','.join(e.triggers)
196- print(rformat % (e.number, e.source_package, e.series, e.arch, ppa_str, trig_str))
197+ print(" - " + rformat % (e.number, e.source_package, e.series, e.arch, ppa_str, trig_str))
198 if n == 0:
199- print("Waiting: (none)")
200+ print("* Waiting: (none)")
201
202
203 if __name__ == "__main__":
204@@ -202,11 +202,11 @@ if __name__ == "__main__":
205
206 print("running:")
207 response = urlopen(f"file://{root_dir}/tests/data/running-20220822.json")
208- for job in get_running(response, 'kinetic', ppa):
209+ for job in get_running(response, ['kinetic'], ppa):
210 print(job)
211 print()
212
213 print("waiting:")
214 response = urlopen(f"file://{root_dir}/tests/data/queues-20220822.json")
215- for job in get_waiting(response, 'kinetic', ppa):
216+ for job in get_waiting(response, ['kinetic'], ppa):
217 print(job)
218diff --git a/ppa/ppa.py b/ppa/ppa.py
219index 4140873..7b6f16f 100755
220--- a/ppa/ppa.py
221+++ b/ppa/ppa.py
222@@ -8,6 +8,8 @@
223 # Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
224 # more information.
225
226+"""A wrapper around a Launchpad Personal Package Archive object."""
227+
228 import re
229
230 from textwrap import indent
231@@ -32,7 +34,7 @@ class PpaDoesNotExist(BaseException):
232 """Printable error message.
233
234 :rtype str:
235- :return: Error message about the failure
236+ :return: Error message about the failure.
237 """
238 if self.message:
239 return self.message
240@@ -40,7 +42,7 @@ class PpaDoesNotExist(BaseException):
241
242
243 class Ppa:
244- """Encapsulates data needed to access and conveniently wrap a PPA
245+ """Encapsulates data needed to access and conveniently wrap a PPA.
246
247 This object proxies a PPA, allowing lazy initialization and caching
248 of data from the remote.
249@@ -88,6 +90,7 @@ class Ppa:
250 return f"{self.team_name}/{self.name}"
251
252 @property
253+ @lru_cache
254 def archive(self):
255 """Retrieves the LP Archive object from the Launchpad service.
256
257@@ -101,6 +104,15 @@ class Ppa:
258 except NotFound:
259 raise PpaDoesNotExist(self.ppa_name, self.team_name)
260
261+ @lru_cache
262+ def exists(self) -> bool:
263+ """Returns true if the PPA exists in Launchpad."""
264+ try:
265+ self.archive
266+ return True
267+ except PpaDoesNotExist:
268+ return False
269+
270 @property
271 @lru_cache
272 def address(self):
273@@ -157,6 +169,7 @@ class Ppa:
274 return retval and self.archive.description == description
275
276 @property
277+ @lru_cache
278 def architectures(self):
279 """Returns the architectures configured to build packages in the PPA.
280
281@@ -408,7 +421,7 @@ def get_das(distro, series_name, arch_name):
282
283
284 def get_ppa(lp, config):
285- """Load the specified PPA from Launchpad
286+ """Load the specified PPA from Launchpad.
287
288 :param Lp lp: The Launchpad wrapper object.
289 :param dict config: Configuration param:value map.
290diff --git a/ppa/ppa_group.py b/ppa/ppa_group.py
291index ce1c0d1..622cf76 100755
292--- a/ppa/ppa_group.py
293+++ b/ppa/ppa_group.py
294@@ -8,12 +8,14 @@
295 # Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
296 # more information.
297
298-from .ppa import Ppa
299-from .text import o2str
300+"""A team or person that owns one or more PPAs in Launchpad."""
301
302 from functools import lru_cache
303 from lazr.restfulclient.errors import BadRequest
304
305+from .ppa import Ppa
306+from .text import o2str
307+
308
309 class PpaAlreadyExists(BaseException):
310 """Exception indicating a PPA operation could not be performed."""
311@@ -28,10 +30,10 @@ class PpaAlreadyExists(BaseException):
312 self.message = message
313
314 def __str__(self):
315- """Printable error message
316+ """Printable error message.
317
318 :rtype str:
319- :return: Error message about the failure
320+ :return: Error message about the failure.
321 """
322 if self.message:
323 return self.message
324@@ -51,7 +53,11 @@ class PpaGroup:
325 :param launchpadlib.service service: The Launchpad service object.
326 :param str name: Launchpad username or team name.
327 """
328- assert service is not None
329+ if not service:
330+ raise ValueError("undefined service.")
331+ if not name:
332+ raise ValueError("undefined name.")
333+
334 self.service = service
335 self.name = name
336
337@@ -116,9 +122,10 @@ class PpaGroup:
338 @property
339 @lru_cache
340 def ppas(self):
341- """Generator to access the PPAs in this group
342+ """Generator to access the PPAs in this group.
343+
344 :rtype: Iterator[ppa.Ppa]
345- :returns: Each PPA in the group as a ppa.Ppa object
346+ :returns: Each PPA in the group as a ppa.Ppa object.
347 """
348 for lp_ppa in self.team.ppas:
349 if '-deletedppa' in lp_ppa.name:
350@@ -129,6 +136,7 @@ class PpaGroup:
351 @lru_cache
352 def get(self, ppa_name):
353 """Provides a Ppa for the named ppa.
354+
355 :rtype: ppa.Ppa
356 :returns: A Ppa object describing the named ppa.
357 """
358diff --git a/ppa/result.py b/ppa/result.py
359index 012165d..db0af48 100755
360--- a/ppa/result.py
361+++ b/ppa/result.py
362@@ -13,7 +13,7 @@
363
364 import re
365 import urllib.request
366-from functools import lru_cache, cached_property
367+from functools import lru_cache
368 from typing import List, Iterator
369 import gzip
370 import time
371@@ -65,14 +65,16 @@ class Result:
372 :rtype: str
373 :returns: Printable summary of the object.
374 """
375- return f"{self.source} on {self.series} for {self.arch} @ {self.timestamp}"
376+ pad = ' ' * (1 + abs(len('ppc64el') - len(self.arch)))
377+ return f"{self.source} on {self.series} for {self.arch}{pad}@ {self.timestamp}"
378
379 @property
380 def timestamp(self) -> str:
381 """Formats the result's completion time as a string."""
382 return time.strftime("%d.%m.%y %H:%M:%S", self.time)
383
384- @cached_property
385+ @property
386+ @lru_cache
387 def log(self) -> str:
388 """Returns log contents for results, downloading if necessary.
389
390@@ -103,7 +105,8 @@ class Result:
391 return None
392
393 # TODO: Merge triggers and get_triggers()
394- @cached_property
395+ @property
396+ @lru_cache
397 def triggers(self) -> List[str]:
398 """Returns package/version parameters used to generate this Result.
399
400@@ -165,7 +168,8 @@ class Result:
401 subtests.append(Subtest(line))
402 return subtests
403
404- @cached_property
405+ @property
406+ @lru_cache
407 def status(self) -> str:
408 """Returns overall status of all subtests
409
410@@ -187,7 +191,8 @@ class Result:
411 return 'FAIL'
412 return 'PASS'
413
414- @cached_property
415+ @property
416+ @lru_cache
417 def status_icon(self) -> str:
418 """Unicode symbol corresponding to test's overall status.
419
420diff --git a/ppa/subtest.py b/ppa/subtest.py
421index 2f18190..8d871c4 100755
422--- a/ppa/subtest.py
423+++ b/ppa/subtest.py
424@@ -11,7 +11,7 @@
425
426 """An individual DEP8 test run"""
427
428-from functools import cached_property
429+from functools import lru_cache
430
431
432 class Subtest:
433@@ -55,7 +55,8 @@ class Subtest:
434 """
435 return f"{self.desc:25} {self.status:6} {self.status_icon}"
436
437- @cached_property
438+ @property
439+ @lru_cache
440 def desc(self) -> str:
441 """The descriptive text for the given subtest.
442
443@@ -64,7 +65,8 @@ class Subtest:
444 """
445 return next(iter(self._line.split()), '')
446
447- @cached_property
448+ @property
449+ @lru_cache
450 def status(self) -> str:
451 """The success or failure of the given subtest.
452
453@@ -76,7 +78,8 @@ class Subtest:
454 return k
455 return 'UNKNOWN'
456
457- @cached_property
458+ @property
459+ @lru_cache
460 def status_icon(self) -> str:
461 """Unicode symbol corresponding to subtest's status.
462
463diff --git a/ppa/text.py b/ppa/text.py
464index b1e1453..ebd3ec3 100644
465--- a/ppa/text.py
466+++ b/ppa/text.py
467@@ -69,8 +69,8 @@ def o2str(obj):
468
469 @lru_cache
470 def to_bool(value):
471- """
472- Converts 'something' to boolean. Raises exception for invalid formats
473+ """Converts 'something' to boolean. Raises exception for invalid formats.
474+
475 Possible True values: 1, True, '1', 'TRue', 'yes', 'y', 't'
476 Possible False values: 0, False, None, [], {}, '', '0', 'faLse', 'no', 'n', 'f', 0.0
477 """
478@@ -116,31 +116,16 @@ def o2float(value):
479 raise
480
481
482+def ansi_hyperlink(url, text):
483+ """Formats text into a hyperlink using ANSI escape codes.
484+
485+ :param str url: The linked action to load in a web browser.
486+ :param str text: The visible text to show.
487+ :rtype: str
488+ :returns: ANSI escape code sequence to display the hyperlink.
489+ """
490+ return f"\u001b]8;;{url}\u001b\\{text}\u001b]8;;\u001b\\"
491+
492+
493 if __name__ == "__main__":
494- test_cases = [
495- ('true', True),
496- ('t', True),
497- ('yes', True),
498- ('y', True),
499- ('1', True),
500- ('false', False),
501- ('f', False),
502- ('no', False),
503- ('n', False),
504- ('0', False),
505- ('', False),
506- (1, True),
507- (0, False),
508- (1.0, True),
509- (0.0, False),
510- ([], False),
511- ({}, False),
512- ((), False),
513- ([1], True),
514- ({1: 2}, True),
515- ((1,), True),
516- (None, False),
517- (object(), True),
518- ]
519- for test, expected in test_cases:
520- assert to_bool(test) == expected, f"to_bool({test}) failed to return {expected}"
521+ print(ansi_hyperlink("https://launchpad.net/ppa-dev-tools", "ppa-dev-tools"))
522diff --git a/ppa/trigger.py b/ppa/trigger.py
523index ae00b44..72f8ba6 100755
524--- a/ppa/trigger.py
525+++ b/ppa/trigger.py
526@@ -11,7 +11,7 @@
527
528 """A directive to run a DEP8 test against a source package"""
529
530-from functools import cached_property
531+from functools import lru_cache
532 from urllib.parse import urlencode
533
534 from .constants import URL_AUTOPKGTEST
535@@ -60,7 +60,8 @@ class Trigger:
536 """
537 return f"{self.package}/{self.version}"
538
539- @cached_property
540+ @property
541+ @lru_cache
542 def history_url(self) -> str:
543 """Renders the trigger as a URL to the job history.
544
545@@ -74,7 +75,8 @@ class Trigger:
546 pkg_str = f"{prefix}/{self.package}"
547 return f"{URL_AUTOPKGTEST}/packages/{pkg_str}/{self.series}/{self.arch}"
548
549- @cached_property
550+ @property
551+ @lru_cache
552 def action_url(self) -> str:
553 """Renders the trigger as a URL to start running the test.
554
555diff --git a/scripts/ppa b/scripts/ppa
556index 04abd4c..09ba1e7 100755
557--- a/scripts/ppa
558+++ b/scripts/ppa
559@@ -51,7 +51,7 @@ import sys
560 import time
561 import argparse
562 from inspect import currentframe
563-from distro_info import UbuntuDistroInfo
564+from distro_info import UbuntuDistroInfo, DistroDataOutdated
565
566 try:
567 from ruamel import yaml
568@@ -88,11 +88,11 @@ from ppa.result import (
569 Result,
570 get_results
571 )
572-from ppa.text import o2str
573+from ppa.text import o2str, ansi_hyperlink
574 from ppa.trigger import Trigger
575
576 import ppa.debug
577-from ppa.debug import dbg, warn
578+from ppa.debug import dbg, warn, error
579
580
581 def UNIMPLEMENTED():
582@@ -192,6 +192,18 @@ def create_arg_parser():
583 tests_parser.add_argument('ppa_name', metavar='ppa-name',
584 action='store',
585 help="Name of the PPA to view tests")
586+ tests_parser.add_argument('-a', '--arches', '--arch', '--architectures',
587+ dest="architectures", action='store',
588+ help="Comma-separated list of hardware architectures to include in triggers")
589+ tests_parser.add_argument('-r', '--releases', '--release',
590+ dest="releases", action='store',
591+ help="Comma-separated list of Ubuntu release codenames to show triggers for")
592+ tests_parser.add_argument('-p', '--packages', '--package',
593+ dest="packages", action='store',
594+ help="Comma-separated list of source package names to show triggers for")
595+ tests_parser.add_argument('-L', '--show-urls',
596+ dest='show_urls', action='store_true',
597+ help="Display unformatted trigger action URLs")
598
599 # Wait Command
600 wait_parser = subparser.add_parser('wait', help='wait help')
601@@ -260,6 +272,13 @@ def create_config(lp, args):
602 else:
603 warn(f"Invalid architectures '{args.architectures}'")
604 return None
605+ elif args.command == 'tests':
606+ if args.releases is not None:
607+ if args.releases:
608+ config['releases'] = args.releases.split(',')
609+ else:
610+ warn(f"Invalid releases '{args.releases}'")
611+ return None
612
613 return config
614
615@@ -550,49 +569,88 @@ def command_tests(lp, config):
616 if not lp:
617 return 1
618
619- # Show tests only from the current development release
620- udi = UbuntuDistroInfo()
621- release = udi.devel()
622+ releases = config.get('releases', None)
623+ if releases is None:
624+ udi = UbuntuDistroInfo()
625+ try:
626+ # Show tests only from the current development release
627+ releases = [ udi.devel() ]
628+ except DistroDataOutdated as e:
629+ # If no development release defined, use the current active release
630+ warn(f"Devel release is undefined; assuming stable release instead.")
631+ dbg(f"({e})", wrap=72, prefix=' - ', indent=' ')
632+ releases = [ udi.stable() ]
633+
634+ packages = config.get('packages', None)
635+
636+ ppa = get_ppa(lp, config)
637+ if not ppa.exists():
638+ error(f"PPA {ppa.name} does not exist for user {ppa.team_name}")
639+ return 1
640+
641+ architectures = config.get('architectures', ARCHES_AUTOPKGTEST)
642
643 try:
644- ppa = get_ppa(lp, config)
645+ # Triggers
646+ print("* Triggers:")
647 for source_pub in ppa.get_source_publications():
648- # Triggers
649- for arch in ARCHES_AUTOPKGTEST:
650- trigger = Trigger(
651- package=source_pub.source_package_name,
652- version=source_pub.source_package_version,
653- arch=arch,
654- series=source_pub.distro_series.name,
655- ppa=ppa)
656- print(trigger.action_url)
657+ series = source_pub.distro_series.name
658+ if series not in releases:
659+ continue
660+ pkg = source_pub.source_package_name
661+ if packages and (pkg not in packages):
662+ continue
663+ ver = source_pub.source_package_version
664+ url = f"https://launchpad.net/ubuntu/+source/{pkg}/{ver}"
665+ source_hyperlink = ansi_hyperlink(url, f"{pkg}/{ver}")
666+ print(f" - Source {source_hyperlink}: {source_pub.status}")
667+ triggers = [Trigger(pkg, ver, arch, series, ppa) for arch in architectures]
668+
669+ if config.get("show_urls"):
670+ for trigger in triggers:
671+ print(f" + {trigger.arch}: {trigger.action_url}♻️ ")
672+ for trigger in triggers:
673+ print(f" + {trigger.arch}: {trigger.action_url}💍")
674+
675+ else:
676+ for trigger in triggers:
677+ pad = ' ' * (1 + abs(len('ppc64el') - len(trigger.arch)))
678+ basic_trig = ansi_hyperlink(trigger.action_url, f"Trigger basic @{trigger.arch}♻️ ")
679+ all_proposed_trig = ansi_hyperlink(trigger.action_url + "&all-proposed=1",
680+ f"Trigger all-proposed @{trigger.arch}💍")
681+ print(f" + " + pad.join([basic_trig, all_proposed_trig]))
682
683 # Results
684- base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"
685- base_results_url = base_results_fmt % (release, ppa.team_name, ppa.name)
686- url = f"{base_results_url}?format=plain"
687- response = open_url(url)
688- if response:
689- for result in get_results(response, base_results_url, arches=ARCHES_AUTOPKGTEST):
690- print(f"* {result} {result.status_icon}")
691- print(f" - Triggers: " + ', '.join([str(r) for r in result.get_triggers()]))
692- if result.status != 'PASS':
693- print(f" - Status: {result.status}")
694- print(f" - Log: {result.url}")
695- for subtest in result.get_subtests():
696- print(f" - {subtest}")
697- print()
698+ print("* Results:")
699+ for release in releases:
700+ base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"
701+ base_results_url = base_results_fmt % (release, ppa.team_name, ppa.name)
702+ url = f"{base_results_url}?format=plain"
703+ response = open_url(url)
704+ if response:
705+ trigger_sets = {}
706+ for result in get_results(response, base_results_url, arches=architectures):
707+ trigger = ', '.join([str(r) for r in result.get_triggers()])
708+ trigger_sets.setdefault(trigger, '')
709+ trigger_sets[trigger] += f" + {result.status_icon} {result}\n"
710+ if result.status != 'PASS':
711+ trigger_sets[trigger] += f" • Status: {result.status}\n"
712+ trigger_sets[trigger] += f" • Log: {result.url}\n"
713+ for subtest in result.get_subtests():
714+ trigger_sets[trigger] += f" • {subtest}\n"
715+ for trigger, result in trigger_sets.items():
716+ print(f" - {trigger}\n{result}")
717
718 # Running Queue
719 response = open_url(f"{URL_AUTOPKGTEST}/static/running.json", "running autopkgtests")
720 if response:
721- show_running(sorted(get_running(response, series=release, ppa=str(ppa)),
722- key=lambda k: str(k.submit_time)))
723+ show_running(sorted(get_running(response, releases=releases, ppa=str(ppa)),
724+ key=lambda k: str(k.submit_time)))
725
726 # Waiting Queue
727 response = open_url(f"{URL_AUTOPKGTEST}/queues.json", "waiting autopkgtests")
728 if response:
729- show_waiting(get_waiting(response, series=release, ppa=str(ppa)))
730+ show_waiting(get_waiting(response, releases=releases, ppa=str(ppa)))
731
732 return os.EX_OK
733 except KeyboardInterrupt:
734@@ -640,7 +698,7 @@ def main(args):
735 return func(lp, config, param)
736 return func(lp, config)
737 except KeyError:
738- parser.error("No such command {}".format(args.command))
739+ parser.error(f"No such command {args.command}")
740 return 1
741
742
743diff --git a/tests/test_job.py b/tests/test_job.py
744index 78a12c7..1d1708b 100644
745--- a/tests/test_job.py
746+++ b/tests/test_job.py
747@@ -81,7 +81,7 @@ def test_get_running():
748 '"Log Output Here"'
749 '] } } } }')
750 fake_response = RequestResponseMock(json_text)
751- job = next(get_running(fake_response, series='focal', ppa='ppa:me/myppa'))
752+ job = next(get_running(fake_response, releases=['focal'], ppa='ppa:me/myppa'))
753 assert repr(job) == "Job(source_package='mypackage', series='focal', arch='arm64')"
754 assert job.triggers == ["yourpackage/1.2.3"]
755 assert job.ppas == ["ppa:me/myppa"]
756@@ -100,7 +100,7 @@ def test_get_waiting():
757 ' \\"triggers\\": [ \\"c/3.2-1\\", \\"d/2-2\\" ] }"'
758 '] } } }')
759 fake_response = RequestResponseMock(json_text)
760- job = next(get_waiting(fake_response, series='focal', ppa='ppa:me/myppa'))
761+ job = next(get_waiting(fake_response, releases=['focal'], ppa='ppa:me/myppa'))
762 assert job
763 assert job.source_package == "b"
764 assert job.ppas == ['ppa:me/myppa']
765diff --git a/tests/test_ppa_group.py b/tests/test_ppa_group.py
766index 63907fd..f8aa8db 100644
767--- a/tests/test_ppa_group.py
768+++ b/tests/test_ppa_group.py
769@@ -23,9 +23,14 @@ from tests.helpers import PersonMock, LaunchpadMock, LpServiceMock
770
771 def test_object():
772 """Checks that PpaGroup objects can be instantiated."""
773- ppa_group = PpaGroup(service=LpServiceMock(), name=None)
774+ ppa_group = PpaGroup(service=LpServiceMock(), name='test-ppa')
775 assert ppa_group
776
777+ with pytest.raises(ValueError):
778+ ppa_group = PpaGroup(service=LpServiceMock(), name=None)
779+ with pytest.raises(ValueError):
780+ ppa_group = PpaGroup(service=None, name='test-ppa')
781+
782
783 def test_create_ppa():
784 """Checks that PpaGroups can create PPAs."""
785diff --git a/tests/test_result.py b/tests/test_result.py
786index 8c498a4..d928efb 100644
787--- a/tests/test_result.py
788+++ b/tests/test_result.py
789@@ -41,7 +41,7 @@ def test_str():
790 """Checks Result object textual presentation."""
791 timestamp = time.strptime('20030201_040506', "%Y%m%d_%H%M%S")
792 result = Result('url', timestamp, 'b', 'c', 'd')
793- assert f"{result}" == 'd on b for c @ 01.02.03 04:05:06'
794+ assert f"{result}" == 'd on b for c @ 01.02.03 04:05:06'
795
796
797 def test_timestamp():
798diff --git a/tests/test_scripts_ppa.py b/tests/test_scripts_ppa.py
799index 812b4b5..51a0755 100644
800--- a/tests/test_scripts_ppa.py
801+++ b/tests/test_scripts_ppa.py
802@@ -112,7 +112,7 @@ def test_create_arg_parser():
803 args.debug = None
804
805 # Check -q, --dry-run
806- args = parser.parse_args(['cmd', '--dry-run'])
807+ args = parser.parse_args(['--dry-run', 'status', 'test-ppa'])
808 assert args.dry_run == True
809 args.dry_run = None
810
811@@ -159,11 +159,18 @@ def test_create_arg_parser_create():
812 args = parser.parse_args(['create', 'test-ppa'])
813 assert args.ppa_name == 'test-ppa'
814
815- # Check -a, --arch, --arches, --architectures
816+ # Check that command args can come before or after the ppa name
817 args = parser.parse_args(['create', 'test-ppa', '-a', 'x'])
818 assert args.architectures == 'x'
819 args.architectures = None
820+ args = parser.parse_args(['create', '-a', 'x', 'test-ppa'])
821+ assert args.architectures == 'x'
822+ args.architectures = None
823
824+ # Check -a, --arch, --arches, --architectures
825+ args = parser.parse_args(['create', 'test-ppa', '-a', 'x'])
826+ assert args.architectures == 'x'
827+ args.architectures = None
828 args = parser.parse_args(['create', 'test-ppa', '--arch', 'x'])
829 assert args.architectures == 'x'
830 args.architectures = None
831@@ -175,6 +182,72 @@ def test_create_arg_parser_create():
832 args.architectures = None
833
834
835+def test_create_arg_parser_tests():
836+ """Checks argument parsing for the 'tests' command."""
837+ parser = script.create_arg_parser()
838+
839+ # Check ppa_name
840+ args = parser.parse_args(['tests', 'test-ppa'])
841+ assert args.ppa_name == 'test-ppa'
842+
843+ # Check -a, --arch, --arches, --architectures
844+ args = parser.parse_args(['tests', 'test-ppa', '-a', 'x'])
845+ assert args.architectures == 'x'
846+ args.architectures = None
847+
848+ args = parser.parse_args(['tests', 'test-ppa', '--arch', 'x'])
849+ assert args.architectures == 'x'
850+ args.architectures = None
851+ args = parser.parse_args(['tests', 'test-ppa', '--arches', 'x'])
852+ assert args.architectures == 'x'
853+ args.architectures = None
854+ args = parser.parse_args(['tests', 'test-ppa', '--architectures', 'a,b,c'])
855+ assert args.architectures == 'a,b,c'
856+ args.architectures = None
857+
858+ # Check -r, --release, --releases
859+ args = parser.parse_args(['tests', 'test-ppa', '-r', 'x'])
860+ assert args.releases == 'x'
861+ args.releases = None
862+ args = parser.parse_args(['tests', 'test-ppa', '--release', 'x'])
863+ assert args.releases == 'x'
864+ args.releases = None
865+ args = parser.parse_args(['tests', 'test-ppa', '--releases', 'x'])
866+ assert args.releases == 'x'
867+ args.releases = None
868+ args = parser.parse_args(['tests', 'test-ppa', '--releases', 'x,y,z'])
869+ assert args.releases == 'x,y,z'
870+ args.releases = None
871+
872+ # Check -p, --package, --packages
873+ args = parser.parse_args(['tests', 'tests-ppa', '-p', 'x'])
874+ assert args.packages == 'x'
875+ args.packages = None
876+ args = parser.parse_args(['tests', 'tests-ppa', '--package', 'x'])
877+ assert args.packages == 'x'
878+ args.packages = None
879+ args = parser.parse_args(['tests', 'tests-ppa', '--packages', 'x'])
880+ assert args.packages == 'x'
881+ args.packages = None
882+ args = parser.parse_args(['tests', 'tests-ppa', '--packages', 'x,y,z'])
883+ assert args.packages == 'x,y,z'
884+ args.packages = None
885+
886+ # Check --show-urls, --show-url, -L
887+ args = parser.parse_args(['tests', 'tests-ppa'])
888+ assert args.show_urls == False
889+ args.show_urls = None
890+ args = parser.parse_args(['tests', 'tests-ppa', '--show-urls'])
891+ assert args.show_urls == True
892+ args.show_urls = None
893+ args = parser.parse_args(['tests', 'tests-ppa', '--show-url'])
894+ assert args.show_urls == True
895+ args.show_urls = None
896+ args = parser.parse_args(['tests', 'tests-ppa', '-L'])
897+ assert args.show_urls == True
898+ args.show_urls = None
899+
900+
901 @pytest.mark.xfail(reason="Unimplemented")
902 def test_create_config():
903 # args = []
904diff --git a/tests/test_text.py b/tests/test_text.py
905new file mode 100644
906index 0000000..57ce979
907--- /dev/null
908+++ b/tests/test_text.py
909@@ -0,0 +1,54 @@
910+#!/usr/bin/env python3
911+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
912+
913+# Author: Bryce Harrington <bryce@canonical.com>
914+#
915+# Copyright (C) 2022 Bryce W. Harrington
916+#
917+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
918+# more information.
919+
920+import os
921+import sys
922+
923+import pytest
924+
925+sys.path.insert(0, os.path.realpath(
926+ os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")))
927+
928+import ppa.text
929+
930+@pytest.mark.parametrize('input, expected', [
931+ ('true', True),
932+ ('t', True),
933+ ('yes', True),
934+ ('y', True),
935+ ('1', True),
936+ ('false', False),
937+ ('f', False),
938+ ('no', False),
939+ ('n', False),
940+ ('0', False),
941+ ('', False),
942+ (1, True),
943+ (0, False),
944+ (1.0, True),
945+ (0.0, False),
946+ ((), False),
947+ ((1,), True),
948+ (None, False),
949+ (object(), True),
950+])
951+def test_to_bool(input, expected):
952+ """Check that the given input produces the expected true/false result.
953+
954+ :param * input: Any available type to be converted to boolean.
955+ :param bool expected: The True or False result to expect.
956+ """
957+ assert ppa.text.to_bool(input) == expected
958+
959+
960+def test_ansi_hyperlink():
961+ """Check that text can be linked with a url."""
962+ assert ppa.text.ansi_hyperlink("xxx", "yyy") == "\u001b]8;;xxx\u001b\\yyy\u001b]8;;\u001b\\"
963+

Subscribers

People subscribed via source and target branches

to all changes: