Merge ppa-dev-tools:test-create-config into ppa-dev-tools:main

Proposed by Bryce Harrington
Status: Merged
Merge reported by: Bryce Harrington
Merged at revision: 6b06eaa91dc8931aea7767a9474a680412a0eff9
Proposed branch: ppa-dev-tools:test-create-config
Merge into: ppa-dev-tools:main
Diff against target: 275 lines (+97/-27)
4 files modified
ppa/ppa.py (+2/-0)
scripts/ppa (+21/-14)
tests/helpers.py (+16/-3)
tests/test_scripts_ppa.py (+58/-10)
Reviewer Review Type Date Requested Status
Athos Ribeiro (community) Approve
Alberto Contreras (community) Needs Information
Canonical Server Reporter Pending
Review via email: mp+438274@code.launchpad.net

Description of the change

This adds unit tests for the create_config() command, which I thought might be a bit superfluous but it did uncover some bad handling of invalid inputs. There's a few other testsuite related fixes and cleanups included.

Best way to test this branch is to run the full testsuite:

    $ pytest-3

The current codebase had a test failure relating to processors; this fixes that, and all subsequent commits should likewise pass the full testsuite.

To post a comment you must log in.
Revision history for this message
Alberto Contreras (aciba) wrote :

Thanks, Bryce, for adding unit tests. The changes look good.

I have left some nits and two questions / concerns.

1) The main one is the inclusion of the mocking machinery in `Ppa.archive`.

This helps us to unit test the code and it is okay as is.

But, adds new code that it is not needed at runtime and could cause problems in the future. In this case, this is probably an exaggeration, as the modifications are simple and easy to track. But if we establish this pattern of allowing testing code within production code, things could get complicated and bite us at some point.

Have we thought about using other alternatives, as using `pytest.fixtures` and/or `unittests.mock` in the long term? Or are there restrictions to use those that I do not see?

2) The xfail comment.

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

Thanks Bryce!

Other than Alberto's questions/comments (in special, the XFAIL one), this LGTM :)

ppa-dev-tools:test-create-config updated
93aba18... by Bryce Harrington

tests: Create mock PPAs with the default processors enabled

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

Hi Alberto and Athos, thank you for reviewing! You're right the xfail can be dropped; I was using it to isolate some debugging work and appear to have forgotten to re-enable it.

Alberto, you're right that the _archive addition is a bit awkward. I took a fresh look this morning, and redid the code to avoid needing to add that, by creating the ArchiveMock just at the point of need, and avoid having to store it. For test code that should be fine, and it avoids impacting the run-time code, which could have side effects as you point out.

I also did the cleanups you suggested, squashed in the code review changes, and force pushed the branch. I think it looks a more solid, thank you both again for taking time to look at this.

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

LGTM.

I just did not get why we needed the dict typecast there. Is it to satisfy type checking?

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

Thanks! Answer to the question below.

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

Landed:

To git+ssh://git.launchpad.net/ppa-dev-tools
   555b089..7e59879 main -> main

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/ppa/ppa.py b/ppa/ppa.py
index dfbe5df..45e9886 100755
--- a/ppa/ppa.py
+++ b/ppa/ppa.py
@@ -97,6 +97,8 @@ class Ppa:
97 :returns: The Launchpad archive object.97 :returns: The Launchpad archive object.
98 :raises PpaDoesNotExist: Raised if a PPA does not exist in Launchpad.98 :raises PpaDoesNotExist: Raised if a PPA does not exist in Launchpad.
99 """99 """
100 if not self._service:
101 raise AttributeError("Ppa object not connected to the Launchpad service")
100 try:102 try:
101 owner = self._service.people[self.team_name]103 owner = self._service.people[self.team_name]
102 return owner.getPPAByName(name=self.ppa_name)104 return owner.getPPAByName(name=self.ppa_name)
diff --git a/scripts/ppa b/scripts/ppa
index 6be138e..ad6a09a 100755
--- a/scripts/ppa
+++ b/scripts/ppa
@@ -52,6 +52,7 @@ import time
52import argparse52import argparse
53from inspect import currentframe53from inspect import currentframe
54from textwrap import indent54from textwrap import indent
55from typing import Any
55from distro_info import UbuntuDistroInfo56from distro_info import UbuntuDistroInfo
5657
57try:58try:
@@ -111,7 +112,7 @@ def load_yaml_as_dict(filename):
111 return d112 return d
112113
113114
114def add_global_options(parser) -> None:115def add_global_options(parser: argparse.ArgumentParser) -> None:
115 """Adds arguments to the given parser for generic options.116 """Adds arguments to the given parser for generic options.
116117
117 :param argparse.ArgumentParser parser: A parser or subparser.118 :param argparse.ArgumentParser parser: A parser or subparser.
@@ -138,7 +139,7 @@ def add_global_options(parser) -> None:
138 help="Minimize output during processing")139 help="Minimize output during processing")
139140
140141
141def add_basic_config_options(parser) -> None:142def add_basic_config_options(parser: argparse.ArgumentParser) -> None:
142 """Adds to a parser the command line options to configure the PPA.143 """Adds to a parser the command line options to configure the PPA.
143144
144 The config options are supported by the 'create' and 'set' command,145 The config options are supported by the 'create' and 'set' command,
@@ -146,6 +147,8 @@ def add_basic_config_options(parser) -> None:
146147
147 These options represent what can be set as a user from the Launchpad148 These options represent what can be set as a user from the Launchpad
148 web interface.149 web interface.
150
151 :param argparse.ArgumentParser parser: A parser or subparser.
149 """152 """
150 # Architectures153 # Architectures
151 parser.add_argument(154 parser.add_argument(
@@ -203,7 +206,7 @@ def add_basic_config_options(parser) -> None:
203 )206 )
204207
205208
206def create_arg_parser():209def create_arg_parser() -> argparse.ArgumentParser:
207 """Sets up the command line parser object.210 """Sets up the command line parser object.
208211
209 :rtype: argparse.ArgumentParser212 :rtype: argparse.ArgumentParser
@@ -364,7 +367,14 @@ def create_arg_parser():
364 return parser367 return parser
365368
366369
367def create_config(lp, args):370DEFAULT_CONFIG = {
371 'debug': False,
372 'ppa_name': None,
373 'team_name': None,
374 'wait_seconds': 10.0
375 }
376
377def create_config(lp: Lp, args: argparse.Namespace) -> dict[str, Any]:
368 """Creates config object by loading from file and adding args.378 """Creates config object by loading from file and adding args.
369379
370 This routine merges the command line parameter values with data380 This routine merges the command line parameter values with data
@@ -380,19 +390,13 @@ def create_config(lp, args):
380 :rtype: dict390 :rtype: dict
381 :returns: dict of configuration parameters and values, or None on error391 :returns: dict of configuration parameters and values, or None on error
382 """392 """
383 DEFAULT_CONFIG = {
384 'debug': False,
385 'ppa_name': None,
386 'team_name': None,
387 'wait_seconds': 10.0
388 }
389 config_path = os.path.expanduser(args.config_filename)393 config_path = os.path.expanduser(args.config_filename)
390 try:394 try:
391 config = load_yaml_as_dict(config_path)395 config = load_yaml_as_dict(config_path)
392 except FileNotFoundError:396 except FileNotFoundError:
393 # Assume defaults397 # Assume defaults
394 dbg("Using default config since no config file found at {}".format(config_path))398 dbg("Using default config since no config file found at {}".format(config_path))
395 config = DEFAULT_CONFIG399 config = dict(DEFAULT_CONFIG)
396400
397 # Map all non-empty elements from argparse Namespace into config dict401 # Map all non-empty elements from argparse Namespace into config dict
398 config.update({k: v for k, v in vars(args).items() if v})402 config.update({k: v for k, v in vars(args).items() if v})
@@ -404,10 +408,13 @@ def create_config(lp, args):
404 lp_username = None408 lp_username = None
405 if lp.me:409 if lp.me:
406 lp_username = lp.me.name410 lp_username = lp.me.name
411 if not hasattr(args, 'ppa_name'):
412 warn("No ppa name given")
413 return None
414
407 config['team_name'], config['ppa_name'] = ppa_address_split(args.ppa_name, lp_username)415 config['team_name'], config['ppa_name'] = ppa_address_split(args.ppa_name, lp_username)
408 if not config['team_name'] or not config['ppa_name']:416 if not config['team_name'] or not config['ppa_name']:
409 warn("Invalid ppa name '{}'".format(args.ppa_name))417 raise ValueError("Invalid ppa name '{}'".format(args.ppa_name))
410 return None
411418
412 if args.dry_run:419 if args.dry_run:
413 config['dry_run'] = True420 config['dry_run'] = True
@@ -877,7 +884,7 @@ COMMANDS = {
877 }884 }
878885
879886
880def main(args):887def main(args: argparse.Namespace) -> int:
881 """Main entrypoint for the command.888 """Main entrypoint for the command.
882889
883 :param argparse.Namespace args: Command line arguments.890 :param argparse.Namespace args: Command line arguments.
diff --git a/tests/helpers.py b/tests/helpers.py
index c6a55eb..07ad462 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -15,18 +15,32 @@ sys.path.insert(0, os.path.realpath(
15 os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")))15 os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")))
1616
17from ppa.ppa import Ppa17from ppa.ppa import Ppa
18from ppa.constants import ARCHES_PPA_DEFAULT
18from ppa.ppa_group import PpaAlreadyExists19from ppa.ppa_group import PpaAlreadyExists
1920
2021
22class ProcessorMock:
23 """A stand-in for a Launchpad Processor object."""
24 def __init__(self, name):
25 self.name = name
26
27
21class ArchiveMock:28class ArchiveMock:
22 """A stand-in for a Launchpad Archive object."""29 """A stand-in for a Launchpad Archive object."""
23 def __init__(self, name, description):30 def __init__(self, name, description):
24 self.displayname = name31 self.displayname = name
25 self.description = description32 self.description = description
33 self.processors = [ProcessorMock(proc_name) for proc_name in ARCHES_PPA_DEFAULT]
2634
27 def lp_save(self):35 def lp_save(self):
28 return True36 return True
2937
38 def setProcessors(self, processors):
39 self.processors = []
40 for proc in processors:
41 proc_name = proc.split('/')[-1]
42 self.processors.append(ProcessorMock(proc_name))
43
3044
31class PersonMock:45class PersonMock:
32 """A stand-in for a Launchpad Person object."""46 """A stand-in for a Launchpad Person object."""
@@ -38,14 +52,13 @@ class PersonMock:
38 for ppa in self._ppas:52 for ppa in self._ppas:
39 if ppa.name == name:53 if ppa.name == name:
40 raise PpaAlreadyExists(name)54 raise PpaAlreadyExists(name)
41 new_ppa = Ppa(name, self.name, description)55 self._ppas.append(Ppa(name, self.name, description))
42 self._ppas.append(new_ppa)
43 return True56 return True
4457
45 def getPPAByName(self, name):58 def getPPAByName(self, name):
46 for ppa in self._ppas:59 for ppa in self._ppas:
47 if ppa.name == name:60 if ppa.name == name:
48 return ArchiveMock(name, ppa.description)61 return ArchiveMock(ppa.name, ppa.description)
49 return None62 return None
5063
51 def lp_save(self):64 def lp_save(self):
diff --git a/tests/test_scripts_ppa.py b/tests/test_scripts_ppa.py
index 10acc30..40c7714 100644
--- a/tests/test_scripts_ppa.py
+++ b/tests/test_scripts_ppa.py
@@ -226,7 +226,7 @@ def test_create_arg_parser_basic_config(command):
226 assert args.description == 'x'226 assert args.description == 'x'
227 args.description = None227 args.description = None
228228
229 # Check --enable / -D|--disable229 # Check --enable / --disable
230 args = parser.parse_args([command, 'test-ppa', '--enable'])230 args = parser.parse_args([command, 'test-ppa', '--enable'])
231 assert args.set_enabled is True231 assert args.set_enabled is True
232 args.set_enabled = False232 args.set_enabled = False
@@ -355,27 +355,75 @@ def test_create_arg_parser_tests():
355 args.show_urls = None355 args.show_urls = None
356356
357357
358@pytest.mark.xfail(reason="Unimplemented")358@pytest.mark.parametrize('command_line_options, expected_config', [
359def test_create_config():359 pytest.param([], {}),
360 # args = []360 (['status', 'ppa:aa/bb'], {'command': 'status', 'team_name': 'aa', 'ppa_name': 'bb'}),
361 # TODO: Set config_filename, ppa_name, team_name in args361 (['status', 'aa/bb'], {'team_name': 'aa', 'ppa_name': 'bb'}),
362 # config = script.create_config(args)362 (['status', 'bb'], {'team_name': 'me', 'ppa_name': 'bb'}),
363 (['--debug', 'status', 'ppa:aa/bb'], {'debug': True}),
364 (['--dry-run', 'status', 'ppa:aa/bb'], {'dry_run': True}),
365 (['--verbose', 'status', 'ppa:aa/bb'], {'verbose': True}),
366 (['--quiet', 'status', 'ppa:aa/bb'], {'quiet': True}),
367 ])
368def test_create_config_from_args(command_line_options, expected_config):
369 '''Checks creation of a config object from an argparser object.
370
371 Prior tests cover the creation of proper args from the command line;
372 this test relies on the already-tested argparse machinery to create
373 various args to pass to create_config() in order to assure that the
374 right config dict is generated in response.
375 '''
376 lp = LpServiceMock()
377 parser = script.create_arg_parser()
378 args = parser.parse_args(command_line_options)
379 config = script.create_config(lp, args)
380
381 expected_result = script.DEFAULT_CONFIG
382 for key, value in expected_config.items():
383 assert key in config.keys()
384 assert config[key] == value
385
386
387@pytest.mark.parametrize('args, expected_exception', [
388 # Bad command
389 ([None], SystemExit),
390 ([''], SystemExit),
391 (['INVALID'], SystemExit),
392 ([1], TypeError),
393
394 # Bad ppa name
395 (['status'], SystemExit),
396 (['status', None], ValueError),
397 (['status', ''], ValueError),
398 (['status', 'INVALID'], ValueError),
399 (['status', 1], TypeError),
400
401 # Bad argument name
402 (['--invalid', 'status', 'ppa:aa/bb'], SystemExit),
403 ])
404def test_create_config_from_args_error(args, expected_exception):
405 '''Checks creation of a config object from an argparser object.'''
406 lp = LpServiceMock()
407 parser = script.create_arg_parser()
363408
364 pass409 with pytest.raises(expected_exception):
410 args = parser.parse_args(args)
411 script.create_config(lp, args)
365412
366413
367def test_command_create(fake_config, monkeypatch):414def test_command_create(fake_config, monkeypatch):
368 lp = LpServiceMock()415 lp = LpServiceMock()
369 monkeypatch.setattr("sys.stdin", io.StringIO('test description'))416 monkeypatch.setattr("sys.stdin", io.StringIO('test description'))
370 assert script.command_create(lp, fake_config) == 0
371417
418 assert script.command_create(lp, fake_config) == 0
372 the_ppa = lp.me.getPPAByName(fake_config['ppa_name'])419 the_ppa = lp.me.getPPAByName(fake_config['ppa_name'])
420 assert the_ppa
373421
374 # Check basic attributes (further coverage is in test_ppa_group.py)422 # Check basic attributes (further coverage is in test_ppa_group.py)
375 assert the_ppa.displayname == fake_config['ppa_name']423 assert the_ppa.displayname == fake_config['ppa_name']
376 assert the_ppa.description == 'test description'424 assert the_ppa.description == 'test description'
377425 assert the_ppa.processors
378 # TODO: Check processors426 assert type(the_ppa.processors) is list
379427
380428
381@pytest.mark.xfail(reason="Unimplemented")429@pytest.mark.xfail(reason="Unimplemented")

Subscribers

People subscribed via source and target branches

to all changes: