Merge ppa-dev-tools:results-class into ppa-dev-tools:main

Proposed by Bryce Harrington
Status: Merged
Merged at revision: 801f354619b8a21dee7350b01fcd32e5aef387a3
Proposed branch: ppa-dev-tools:results-class
Merge into: ppa-dev-tools:main
Diff against target: 786 lines (+673/-6)
10 files modified
.flake8 (+1/-0)
AUTHORS.md (+2/-0)
ppa/result.py (+265/-0)
ppa/subtest.py (+109/-0)
ppa/trigger.py (+80/-0)
tests/test_io.py (+2/-3)
tests/test_job.py (+7/-3)
tests/test_result.py (+66/-0)
tests/test_subtest.py (+86/-0)
tests/test_trigger.py (+55/-0)
Reviewer Review Type Date Requested Status
Athos Ribeiro (community) Approve
Canonical Server Reporter Pending
Review via email: mp+429187@code.launchpad.net

Description of the change

Adds the Subtest, Trigger, and Results classes.

I've run lint/flake on these via the check-scripts command from ubuntu-helpers.

    $ check-scripts ./ppa/result.py
    $ check-scripts ./tests/test_result.py
    ...

Each module has a corresponding test that can be invoked via pytest-3, e.g:

    $ pytest-3 ./tests/test_result.py
    ...

Each module can also be run as a script, as a cheap smoketest:

    $ python3 -m ppa.result

To post a comment you must log in.
Revision history for this message
Athos Ribeiro (athos-ribeiro) wrote :

Thanks, Bryce. LGTM!

I left a few inline comments, including a question, suggestion on a changed test.

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

Answer & re-question for one of your feedback items, inline.

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

On Wed, Aug 31, 2022 at 06:40:02PM -0000, Bryce Harrington wrote:
>Answer & re-question for one of your feedback items, inline.
>
>Diff comments:
>
>> diff --git a/tests/test_job.py b/tests/test_job.py
>> index d9a9967..44491c2 100644
>> --- a/tests/test_job.py
>> +++ b/tests/test_job.py
>> @@ -71,9 +74,10 @@ def test_request_url():
>> jobinfo = {
>> 'triggers': ['t/1'],
>> 'ppas': ['ppa:a/b', 'ppa:c/d']
>> - }
>> + }
>> job = Job(0, 'a', 'b', 'c', 'd', jobinfo['triggers'], jobinfo['ppas'])
>> - assert job.request_url == "https://autopkgtest.ubuntu.com/request.cgi?release=c&arch=d&package=b&trigger=t/1"
>> + assert job.request_url.startswith("https://autopkgtest.ubuntu.com/request.cgi")
>> + assert job.request_url.endswith("?release=c&arch=d&package=b&trigger=t/1")
>
>Yeah it was for shortening the length for the lint warning. Normally I'd do the new variables but figured that would make the testing less clear.
>
>My thinking here for two tests is that it now checks a) is the cgi address correctly inserted, and then b) are the args showing up properly. Do you think it'd be better to just check the whole URL?
>

This was just a nitpick to point out that the test changed. To keep the
behavior, i.e, for the commit to be a refactoring only commit, it should
keep checking the whole thing. It should be OK to merge it as is though
:)

>>
>>
>> def test_get_running():
>
>
>--
>https://code.launchpad.net/~bryce/ppa-dev-tools/+git/ppa-dev-tools-1/+merge/429187
>You are reviewing the proposed merge of ppa-dev-tools:results-class into ppa-dev-tools:main.
>

--
Athos Ribeiro

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

Thanks Athos, yeah while it's not a pure refactor I think splitting the url check to two tests is a good change. I've gone ahead and landed the branch.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.flake8 b/.flake8
2index 7da1f96..d6c1210 100644
3--- a/.flake8
4+++ b/.flake8
5@@ -1,2 +1,3 @@
6 [flake8]
7 max-line-length = 100
8+ignore = E402
9diff --git a/AUTHORS.md b/AUTHORS.md
10index 6138e91..4aef7e4 100644
11--- a/AUTHORS.md
12+++ b/AUTHORS.md
13@@ -1,2 +1,4 @@
14 Bryce Harrington <bryce@canonical.com>
15 Athos Ribeiro <athos.ribeiro@canonical.com>
16+Christian Ehrhardt <christian.ehrhardt@canonical.com>
17+Andy P. Whitcroft <apw@shadowen.org>
18diff --git a/ppa/result.py b/ppa/result.py
19new file mode 100755
20index 0000000..7a2ca5a
21--- /dev/null
22+++ b/ppa/result.py
23@@ -0,0 +1,265 @@
24+#!/usr/bin/env python3
25+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
26+
27+# Copyright (C) 2022 Authors
28+#
29+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
30+# more information.
31+#
32+# Authors:
33+# Bryce Harrington <bryce@canonical.com>
34+
35+"""The completed data from an autopkgtest run"""
36+
37+import re
38+import urllib.request
39+from functools import lru_cache, cached_property
40+from typing import List, Iterator
41+import gzip
42+import time
43+
44+from .subtest import Subtest
45+from .trigger import Trigger
46+
47+
48+class Result:
49+ """
50+ The completed data from an autopkgtest run Job. This object
51+ provides access to the test run's settings and results.
52+ """
53+ VALUES = {
54+ 'PASS': "✅",
55+ 'FAIL': "❌",
56+ 'BAD': "⛔",
57+ }
58+
59+ def __init__(self, url, time, series, arch, source):
60+ """Initializes a new Result object.
61+
62+ :param str url: HTTP path to the test log for this result.
63+ :param str time: The execution time of the test run.
64+ :param str series: The distro release series codename.
65+ :param str arch: The architecture for the result.
66+ :param str source:
67+ """
68+ self.url = url
69+ self.time = time
70+ self.series = series
71+ self.arch = arch
72+ self.source = source
73+ self.error_message = None
74+ self._log = None
75+
76+ def __repr__(self) -> str:
77+ """Machine-parsable unique representation of object.
78+
79+ :rtype: str
80+ :returns: Official string representation of the object.
81+ """
82+ return (f'{self.__class__.__name__}('
83+ f'url={self.url!r})')
84+
85+ def __str__(self) -> str:
86+ """Human-readable summary of the object.
87+
88+ :rtype: str
89+ :returns: Printable summary of the object.
90+ """
91+ return f"{self.source} on {self.series} for {self.arch} @ {self.timestamp}"
92+
93+ @property
94+ def timestamp(self) -> str:
95+ """Formats the result's completion time as a string."""
96+ return time.strftime("%d.%m.%y %H:%M:%S", self.time)
97+
98+ @cached_property
99+ def log(self) -> str:
100+ """Returns log contents for results, downloading if necessary.
101+
102+ Retrieves the log via the result url, handles decompression, and
103+ caches the results internally, so that subsequent calls don't
104+ re-download the data.
105+
106+ On error, returns None and stores the error message in
107+ the Result.error_message property.
108+
109+ :rtype: str
110+ :returns: Full text of the log file, or None on error.
111+ """
112+ request = urllib.request.Request(self.url)
113+ request.add_header('Cache-Control', 'max-age=0')
114+ try:
115+ response = urllib.request.urlopen(request)
116+ except urllib.error.HTTPError as e:
117+ self.error_message = f"Failed to Download Test Log ⚪: {e}"
118+ return None
119+
120+ result_gzip = response.read()
121+ try:
122+ return gzip.decompress(result_gzip).decode("utf-8",
123+ errors="replace")
124+ except UnicodeDecodeError:
125+ self.error_message = "Broken Test Log ⚪"
126+ return None
127+
128+ # TODO: Merge triggers and get_triggers()
129+ @cached_property
130+ def triggers(self) -> List[str]:
131+ """Returns package/version parameters used to generate this Result.
132+
133+ This returns the set of triggers used to create the Result, as
134+ recorded in the test log. Each trigger is a package/version
135+ pair corresponding to source packages to use from the proposed
136+ archive (instead of from the release archive).
137+
138+ :rtype: list[str]
139+ :returns: List of package/version triggers.
140+ """
141+ regex_triggers = re.compile(r'--env=ADT_TEST_TRIGGERS=(.*?) -- ')
142+ header_split = self.log.split(": @@@@@@@@@@@@@@@@@@@@", 1)
143+ if len(header_split) < 2:
144+ return []
145+ m = re.search(regex_triggers, header_split[0])
146+ if not m:
147+ return []
148+
149+ return m.group(1).strip("'").split()
150+
151+ @lru_cache
152+ def get_triggers(self, name=None) -> Iterator[Trigger]:
153+ """Provides list of Triggers that were used to create this Result.
154+
155+ This returns the set of Triggers used to create the Result, as
156+ recorded in the test log. Each trigger identifies a
157+ package/version pair corresponding to source packages to use
158+ from the proposed archive (instead of from the release archive).
159+
160+ :param str name: If defined, only return triggers starting with this name.
161+ :rtype: Iterator[Trigger]
162+ :returns: Triggers used to generate this Result, if any, or an empty list
163+ """
164+ if not self.triggers:
165+ return []
166+
167+ for t in self.triggers:
168+ package, version = t.split('/', 1)
169+ yield Trigger(package, version, arch=self.arch, series=self.series)
170+
171+
172+ @lru_cache
173+ def get_subtests(self, name=None) -> Iterator[Subtest]:
174+ """Provides list of Subtests that were run for this Result.
175+
176+ :param str name: Only display subtests starting with this.
177+ :rtype: Iterator[Subtest]
178+ :returns: Subtests completed for this Result, or empty list.
179+ """
180+ result_split = self.log.split("@@@@@@@@@@@@@@@@@@@@ summary", 1)
181+ if len(result_split) < 2:
182+ return []
183+
184+ result_sum = result_split[1]
185+ for line in re.findall("(.*PASS|.*SKIP|.*FAIL|.*BAD)", result_sum):
186+ if name and not line.startswith(name):
187+ continue
188+ yield Subtest(line)
189+
190+ @cached_property
191+ def status(self) -> str:
192+ """Returns overall status of all subtests
193+
194+ If the triggered run completed successfully, then the status will
195+ be either FAIL if any of the subtests failed, or PASS otherwise.
196+
197+ If the run did not complete successfully, then a 'BAD' status
198+ will be returned, and the reason can be examined via the
199+ Result.error_message property.
200+
201+ :rtype: str
202+ :returns: 'PASS', 'FAIL', or 'BAD'
203+ """
204+ if self.error_message:
205+ return 'BAD'
206+
207+ for subtest in self.get_subtests():
208+ if subtest.status == 'FAIL':
209+ return 'FAIL'
210+ return 'PASS'
211+
212+ @cached_property
213+ def status_icon(self) -> str:
214+ """Unicode symbol corresponding to test's overall status.
215+
216+ :rtype: str
217+ :returns: Unicode symbol
218+ """
219+ return Result.VALUES[self.status]
220+
221+
222+def get_results(response, base_url, arches=None, sources=None) -> Iterator[Result]:
223+ """Returns iterator of Results from the base URL for given criteria
224+
225+ Retrieves the autopkgtest results limited to the given architectures
226+ and source packages. If unspecified, returns all results.
227+
228+ :param str base_url: URL for the autopkgtest results.
229+ :param list[str] arches: Architectures to include in results.
230+ :param list[str] sources: Source packages to include in results.
231+ :rtype: Iterator[Result]
232+ :returns: Iterable results, if any, or an empty list on error
233+ """
234+ for line in response.read().split(b'\n'):
235+ if line == b'' or not line.endswith(b"log.gz"):
236+ continue
237+ result = line.decode("utf-8")
238+ series, arch, _, source, timestamp = result.split('/')[:5]
239+ if (arches and (arch not in arches)):
240+ continue
241+ if (sources and (source not in sources)):
242+ continue
243+ yield Result(
244+ url=base_url + result,
245+ time=time.strptime(timestamp[:-7], "%Y%m%d_%H%M%S"),
246+ series=series,
247+ arch=arch,
248+ source=source)
249+
250+
251+if __name__ == "__main__":
252+ import os
253+
254+ from ppa.io import open_url
255+ from ppa.constants import ARCHES_AUTOPKGTEST, URL_AUTOPKGTEST
256+
257+ print("### Result class smoke test ###")
258+
259+ timestamp = time.strptime('20030201_040506', "%Y%m%d_%H%M%S")
260+ result_1 = Result('url-here', timestamp, 'kinetic', 'amd64', 'my-package')
261+ print(repr(result_1))
262+ print(result_1)
263+ print()
264+
265+ data_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../tests/data"))
266+ url = f"file://{data_dir}/results-six-s390x.log.gz"
267+ result_2 = Result(url, timestamp, 'kinetic', 'amd64', 'my-package')
268+ print("Log Head:")
269+ print("\n".join(result_2.log.splitlines()[0:4]))
270+ print()
271+
272+ # TODO: Implement something that dumps the passing tests for given package from -proposed
273+ # TODO: Filter to items with only Pass, Not a regression, or No test results
274+
275+ print("Loading live excuses data")
276+ base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"
277+ base_results_url = base_results_fmt % ('kinetic', 'bryce', 'nginx-merge-v1.22.0-1')
278+ url = f"{base_results_url}?format=plain"
279+ response = open_url(url)
280+
281+ for result in get_results(response, base_results_url, arches=ARCHES_AUTOPKGTEST):
282+ print(f"* {result}")
283+ print(f" - Triggers: " + ', '.join([str(r) for r in result.get_triggers()]))
284+
285+ for subtest in result.get_subtests():
286+ print(f" - {subtest}")
287+
288+ print()
289diff --git a/ppa/subtest.py b/ppa/subtest.py
290new file mode 100755
291index 0000000..c63dda6
292--- /dev/null
293+++ b/ppa/subtest.py
294@@ -0,0 +1,109 @@
295+#!/usr/bin/env python3
296+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
297+
298+# Copyright (C) 2022 Authors
299+#
300+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
301+# more information.
302+#
303+# Authors:
304+# Bryce Harrington <bryce@canonical.com>
305+
306+"""An individual DEP8 test run"""
307+
308+from functools import cached_property
309+from typing import List, Iterator
310+
311+
312+class Subtest:
313+ """
314+ A triggered autopkgtest can invoke multiple DEP8 tests, such as running
315+ checks on dependencies, the software's testsuite, and integration tests.
316+ Each of these is considered a "Subtest"
317+ """
318+ VALUES = {
319+ 'PASS': "🟩",
320+ 'SKIP': "🟧",
321+ 'FAIL': "🟥",
322+ 'BAD': "⛔",
323+ 'UNKNOWN': "⚪"
324+ }
325+
326+ def __init__(self, line):
327+ """Initializes a new Subtext object.
328+
329+ :param str line: The subtest result summary from a test log.
330+ """
331+ if not line:
332+ raise ValueError("undefined line.")
333+
334+ self._line = line
335+
336+ def __repr__(self) -> str:
337+ """Machine-parsable unique representation of object.
338+
339+ :rtype: str
340+ :returns: Official string representation of the object.
341+ """
342+ return (f'{self.__class__.__name__}('
343+ f'line={self._line!r})')
344+
345+ def __str__(self) -> str:
346+ """Human-readable summary of the object.
347+
348+ :rtype: str
349+ :returns: Printable summary of the object.
350+ """
351+ return f"{self.status_icon} {self.desc}: {self.status}"
352+
353+ @cached_property
354+ def desc(self) -> str:
355+ """The descriptive text for the given subtest.
356+
357+ :rtype: str
358+ :returns: Descriptive text.
359+ """
360+ d = self._line.rsplit(':', 1)
361+ if not d:
362+ return ''
363+ return d[0]
364+
365+ @cached_property
366+ def status(self) -> str:
367+ """The success or failure of the given subtest.
368+
369+ :rtype: str
370+ :returns: Status term in capitalized letters (PASS, FAIL, etc.)
371+ """
372+ for k in Subtest.VALUES:
373+ if self._line.endswith(k):
374+ return k
375+ return 'UNKNOWN'
376+
377+ @cached_property
378+ def status_icon(self) -> str:
379+ """Unicode symbol corresponding to subtest's status.
380+
381+ :rtype: str
382+ :returns: Single unicode character matching the status.
383+ """
384+ return Subtest.VALUES[self.status]
385+
386+
387+if __name__ == "__main__":
388+ print("### Subtest class smoke test ###")
389+ print()
390+
391+ print("Valid cases")
392+ print("-----------")
393+ print(Subtest('subtest a: UNKNOWN'))
394+ print(Subtest('subtest b: PASS'))
395+ print(Subtest('subtest c: FAIL'))
396+ print(Subtest('subtest d: SKIP'))
397+ print(Subtest('subtest e: BAD'))
398+ print()
399+
400+ print("Invalid cases")
401+ print("-------------")
402+ print(Subtest('invalid subtest: invalid'))
403+ print(Subtest('bAd subtest: bAd'))
404diff --git a/ppa/trigger.py b/ppa/trigger.py
405new file mode 100755
406index 0000000..79276da
407--- /dev/null
408+++ b/ppa/trigger.py
409@@ -0,0 +1,80 @@
410+#!/usr/bin/env python3
411+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
412+
413+# Copyright (C) 2022 Authors
414+#
415+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
416+# more information.
417+#
418+# Authors:
419+# Bryce Harrington <bryce@canonical.com>
420+
421+"""A directive to run a DEP8 test against a source package"""
422+
423+from functools import cached_property
424+
425+from .constants import URL_AUTOPKGTEST
426+
427+
428+class Trigger:
429+ """
430+ A trigger indicates a source package that should be installed from a
431+ given series (generally to a specific version) when running
432+ autopkgtests for a Job.
433+
434+ A Job can have multiple Triggers, each against a different source
435+ package and/or architectures, but all such Triggers must be against
436+ the same series as the Job itself.
437+ """
438+ def __init__(self, package, version, arch, series):
439+ """Initializes a new Trigger for a given package and version.
440+
441+ :param str package: The source package name.
442+ :param str version: The version of the source package to install.
443+ :param str arch: The architecture for the trigger.
444+ :param str series: The distro release series codename.
445+ """
446+ self.package = package
447+ self.version = version
448+ self.arch = arch
449+ self.series = series
450+
451+ def __repr__(self) -> str:
452+ """Machine-parsable unique representation of object.
453+
454+ :rtype: str
455+ :returns: Official string representation of the object.
456+ """
457+ return (f'{self.__class__.__name__}('
458+ f'package={self.package!r}, version={self.version!r}, '
459+ f'arch={self.arch!r}, series={self.series!r})')
460+
461+ def __str__(self) -> str:
462+ """Human-readable summary of the object.
463+
464+ :rtype: str
465+ :returns: Printable summary of the object.
466+ """
467+ return f"{self.package}/{self.version}"
468+
469+ @cached_property
470+ def autopkgtest_url(self) -> str:
471+ """Renders the trigger as a URL to the job history.
472+
473+ :rtype: str
474+ :returns: tbd
475+ """
476+ if self.package.startswith('lib'):
477+ prefix = self.package[0:4]
478+ else:
479+ prefix = self.package[0]
480+ pkg_str = f"{prefix}/{self.package}"
481+ return f"{URL_AUTOPKGTEST}/packages/{pkg_str}/{self.series}/{self.arch}"
482+
483+
484+if __name__ == "__main__":
485+ print("### Trigger class smoke test ###")
486+
487+ trigger = Trigger('my-package', '1.2.3', 'amd64', 'kinetic')
488+ print(trigger)
489+ print(trigger.autopkgtest_url)
490diff --git a/tests/data/results-six-s390x.log.gz b/tests/data/results-six-s390x.log.gz
491new file mode 100644
492index 0000000..70d1a80
493Binary files /dev/null and b/tests/data/results-six-s390x.log.gz differ
494diff --git a/tests/test_io.py b/tests/test_io.py
495index fce6611..57f8737 100644
496--- a/tests/test_io.py
497+++ b/tests/test_io.py
498@@ -13,10 +13,9 @@
499 import os
500 import sys
501 import urllib
502-import pytest
503
504 sys.path.insert(0, os.path.realpath(
505- os.path.join(os.path.dirname(__file__), "..")))
506+ os.path.join(os.path.dirname(__file__), "..")))
507
508 from ppa.io import open_url
509
510@@ -28,5 +27,5 @@ def test_open_url(tmp_path):
511
512 request = open_url(f"file://{f}")
513 assert request
514- assert type(request) == urllib.response.addinfourl
515+ assert isinstance(request, urllib.response.addinfourl)
516 assert request.read().decode() == 'abcde'
517diff --git a/tests/test_job.py b/tests/test_job.py
518index d9a9967..44491c2 100644
519--- a/tests/test_job.py
520+++ b/tests/test_job.py
521@@ -14,16 +14,18 @@ import os
522 import sys
523
524 sys.path.insert(0, os.path.realpath(
525- os.path.join(os.path.dirname(__file__), "..")))
526+ os.path.join(os.path.dirname(__file__), "..")))
527
528 from ppa.job import Job, get_running, get_waiting
529
530
531 class ResponseMock:
532+ """Synthetic response object"""
533 def __init__(self, text):
534 self._text = text.encode('utf-8')
535
536 def read(self):
537+ """Simply returns the exact text provided in initializer."""
538 return self._text
539
540
541@@ -34,6 +36,7 @@ def test_object():
542
543
544 def test_init():
545+ """Checks the initialization of a Job object."""
546 job = Job(0, 'a', 'b', 'c', 'd')
547 assert job.number == 0
548 assert job.submit_time == 'a'
549@@ -71,9 +74,10 @@ def test_request_url():
550 jobinfo = {
551 'triggers': ['t/1'],
552 'ppas': ['ppa:a/b', 'ppa:c/d']
553- }
554+ }
555 job = Job(0, 'a', 'b', 'c', 'd', jobinfo['triggers'], jobinfo['ppas'])
556- assert job.request_url == "https://autopkgtest.ubuntu.com/request.cgi?release=c&arch=d&package=b&trigger=t/1"
557+ assert job.request_url.startswith("https://autopkgtest.ubuntu.com/request.cgi")
558+ assert job.request_url.endswith("?release=c&arch=d&package=b&trigger=t/1")
559
560
561 def test_get_running():
562diff --git a/tests/test_result.py b/tests/test_result.py
563new file mode 100644
564index 0000000..836bbd1
565--- /dev/null
566+++ b/tests/test_result.py
567@@ -0,0 +1,66 @@
568+#!/usr/bin/env python3
569+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
570+
571+# Author: Bryce Harrington <bryce@canonical.com>
572+#
573+# Copyright (C) 2022 Bryce W. Harrington
574+#
575+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
576+# more information.
577+
578+"""Results class tests"""
579+
580+import os
581+import sys
582+import time
583+
584+import gzip
585+
586+sys.path.insert(0, os.path.realpath(
587+ os.path.join(os.path.dirname(__file__), "..")))
588+
589+from ppa.result import Result
590+
591+
592+def test_object():
593+ """Checks that Result objects can be instantiated."""
594+ result = Result('url', None, 'b', 'c', 'd')
595+ assert result
596+
597+
598+def test_repr():
599+ """Checks Result object representation."""
600+ result = Result('url', None, 'b', 'c', 'd')
601+ # TODO: Should this include the full set of args?
602+ assert repr(result) == "Result(url='url')"
603+
604+
605+def test_str():
606+ """Checks Result object textual presentation."""
607+ timestamp = time.strptime('20030201_040506', "%Y%m%d_%H%M%S")
608+ result = Result('url', timestamp, 'b', 'c', 'd')
609+ assert f"{result}" == 'd on b for c @ 01.02.03 04:05:06'
610+
611+
612+def test_timestamp():
613+ """Checks Result object formats the result's time correctly."""
614+ timestamp = time.strptime('20030201_040506', "%Y%m%d_%H%M%S")
615+ result = Result('url', timestamp, 'b', 'c', 'd')
616+ assert f"{result.timestamp}" == '01.02.03 04:05:06'
617+
618+
619+def test_log(tmp_path):
620+ """Checks that the log content of a Result is available."""
621+ f = tmp_path / "result.log.gz"
622+ compressed_text = gzip.compress(bytes('abcde', 'utf-8'))
623+ f.write_bytes(compressed_text)
624+
625+ result = Result(f"file://{f}", None, None, None, None)
626+ assert result.log == "abcde"
627+
628+
629+def test_triggers():
630+ """Checks that autopkgtest triggers can be extracted from test result logs."""
631+ data_dir = "/home/bryce/src/PpaDevTools/ppa-dev-tools-result-class/tests/data"
632+ result = Result(f"file://{data_dir}/results-six-s390x.log.gz", None, None, None, None)
633+ assert result.triggers == ['pygobject/3.42.2-2', 'six/1.16.0-4']
634diff --git a/tests/test_subtest.py b/tests/test_subtest.py
635new file mode 100644
636index 0000000..6ca35af
637--- /dev/null
638+++ b/tests/test_subtest.py
639@@ -0,0 +1,86 @@
640+#!/usr/bin/env python3
641+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
642+
643+# Author: Bryce Harrington <bryce@canonical.com>
644+#
645+# Copyright (C) 2022 Bryce W. Harrington
646+#
647+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
648+# more information.
649+
650+"""Subtest class tests"""
651+
652+import os
653+import sys
654+
655+import pytest
656+
657+sys.path.insert(0, os.path.realpath(
658+ os.path.join(os.path.dirname(__file__), "..")))
659+
660+from ppa.subtest import Subtest
661+
662+
663+def test_object():
664+ """Checks that Subtest objects can be instantiated."""
665+ subtest = Subtest('a: UNKNOWN')
666+ # TODO: If no ':' in description, should throw exception?
667+ # TODO: Or add a function that parses lines and returns subtests?
668+ assert subtest
669+
670+
671+def test_repr():
672+ """Checks Subtest object representation."""
673+ subtest = Subtest('a: PASS')
674+ assert repr(subtest) == "Subtest(line='a: PASS')"
675+
676+
677+def test_str():
678+ """Checks Subtest object textual presentation."""
679+ subtest = Subtest('a: PASS')
680+ assert f"{subtest}" == '🟩 a: PASS'
681+
682+
683+def test_desc():
684+ """Checks Subtest description is parsed correctly."""
685+ subtest = Subtest('a: PASS')
686+ assert subtest.desc == 'a'
687+
688+ subtest = Subtest('a:b : c:d: PASS')
689+ assert subtest.desc == 'a:b : c:d'
690+
691+
692+@pytest.mark.parametrize('line, status', [
693+ ('a: PASS', 'PASS'),
694+ ('b: SKIP', 'SKIP'),
695+ ('c: FAIL', 'FAIL'),
696+ ('d: BAD', 'BAD'),
697+ ('e: UNKNOWN', 'UNKNOWN'),
698+ ('f: invalid', 'UNKNOWN'),
699+ ('f: bAd', 'UNKNOWN'),
700+])
701+def test_status(line, status):
702+ """Checks Subtest status is parsed correctly."""
703+ subtest = Subtest(line)
704+ assert subtest.status == status
705+
706+ subtest = Subtest('a: PASS')
707+
708+
709+@pytest.mark.parametrize('line, icon', [
710+ ('a: PASS', "🟩"),
711+ ('b: SKIP', "🟧"),
712+ ('c: FAIL', "🟥"),
713+ ('d: BAD', "⛔"),
714+ ('e: UNKNOWN', "⚪"),
715+ ('f: invalid', "⚪"),
716+ ('f: bAd', "⚪"),
717+])
718+def test_status_icon(line, icon):
719+ """Checks Subtest provides correct icon for status.
720+
721+ :param str line: Subtest status line to be parsed.
722+ :param str icon: Resulting status icon that should be returned.
723+ """
724+ subtest = Subtest(line)
725+ assert subtest.status_icon == icon
726diff --git a/tests/test_trigger.py b/tests/test_trigger.py
727new file mode 100644
728index 0000000..c6b6a6b
729--- /dev/null
730+++ b/tests/test_trigger.py
731@@ -0,0 +1,55 @@
732+#!/usr/bin/env python3
733+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
734+
735+# Author: Bryce Harrington <bryce@canonical.com>
736+#
737+# Copyright (C) 2022 Bryce W. Harrington
738+#
739+# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
740+# more information.
741+
742+"""Trigger class tests"""
743+
744+import os
745+import sys
746+
747+sys.path.insert(0, os.path.realpath(
748+ os.path.join(os.path.dirname(__file__), "..")))
749+
750+from ppa.trigger import Trigger
751+
752+
753+def test_object():
754+ """Checks that Trigger objects can be instantiated."""
755+ trigger = Trigger('a', 'b', 'c', 'd')
756+ assert trigger
757+
758+
759+def test_repr():
760+ """Checks Trigger object representation."""
761+ trigger = Trigger('a', 'b', 'c', 'd')
762+ assert repr(trigger) == "Trigger(package='a', version='b', arch='c', series='d')"
763+
764+
765+def test_str():
766+ """Checks Trigger object textual presentation."""
767+ trigger = Trigger('a', 'b', 'c', 'd')
768+ assert f"{trigger}" == 'a/b'
769+
770+ trigger = Trigger('dovecot', '1:2.3.19.1+dfsg1-2ubuntu2', 'i386', 'kinetic')
771+ assert f"{trigger}" == 'dovecot/1:2.3.19.1+dfsg1-2ubuntu2'
772+
773+
774+def test_autopkgtest_url():
775+ """Checks that Trigger objects generate valid autopkgtest urls"""
776+ trigger = Trigger('a', 'b', 'c', 'd')
777+ expected = "https://autopkgtest.ubuntu.com/packages/a/a/d/c"
778+ assert trigger.autopkgtest_url == expected
779+
780+ trigger = Trigger('apache2', '2.4', 'amd64', 'kinetic')
781+ expected = "https://autopkgtest.ubuntu.com/packages/a/apache2/kinetic/amd64"
782+ assert trigger.autopkgtest_url == expected
783+
784+ trigger = Trigger('libwebsockets', '4.1.6-3', 'armhf', 'jammy')
785+ expected = "https://autopkgtest.ubuntu.com/packages/libw/libwebsockets/jammy/armhf"
786+ assert trigger.autopkgtest_url == expected

Subscribers

People subscribed via source and target branches

to all changes: