Merge ppa-dev-tools:test-create-config into ppa-dev-tools:main
- Git
- lp:ppa-dev-tools
- test-create-config
- Merge into main
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) |
Related bugs: |
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 |
Commit message
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.
Athos Ribeiro (athos-ribeiro) wrote : | # |
Thanks Bryce!
Other than Alberto's questions/comments (in special, the XFAIL one), this LGTM :)
- 93aba18... by Bryce Harrington
-
tests: Create mock PPAs with the default processors enabled
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.
Athos Ribeiro (athos-ribeiro) wrote : | # |
LGTM.
I just did not get why we needed the dict typecast there. Is it to satisfy type checking?
Bryce Harrington (bryce) wrote : | # |
Thanks! Answer to the question below.
Bryce Harrington (bryce) wrote : | # |
Landed:
To git+ssh:
555b089..7e59879 main -> main
Preview Diff
1 | diff --git a/ppa/ppa.py b/ppa/ppa.py | |||
2 | index dfbe5df..45e9886 100755 | |||
3 | --- a/ppa/ppa.py | |||
4 | +++ b/ppa/ppa.py | |||
5 | @@ -97,6 +97,8 @@ class Ppa: | |||
6 | 97 | :returns: The Launchpad archive object. | 97 | :returns: The Launchpad archive object. |
7 | 98 | :raises PpaDoesNotExist: Raised if a PPA does not exist in Launchpad. | 98 | :raises PpaDoesNotExist: Raised if a PPA does not exist in Launchpad. |
8 | 99 | """ | 99 | """ |
9 | 100 | if not self._service: | ||
10 | 101 | raise AttributeError("Ppa object not connected to the Launchpad service") | ||
11 | 100 | try: | 102 | try: |
12 | 101 | owner = self._service.people[self.team_name] | 103 | owner = self._service.people[self.team_name] |
13 | 102 | return owner.getPPAByName(name=self.ppa_name) | 104 | return owner.getPPAByName(name=self.ppa_name) |
14 | diff --git a/scripts/ppa b/scripts/ppa | |||
15 | index 6be138e..ad6a09a 100755 | |||
16 | --- a/scripts/ppa | |||
17 | +++ b/scripts/ppa | |||
18 | @@ -52,6 +52,7 @@ import time | |||
19 | 52 | import argparse | 52 | import argparse |
20 | 53 | from inspect import currentframe | 53 | from inspect import currentframe |
21 | 54 | from textwrap import indent | 54 | from textwrap import indent |
22 | 55 | from typing import Any | ||
23 | 55 | from distro_info import UbuntuDistroInfo | 56 | from distro_info import UbuntuDistroInfo |
24 | 56 | 57 | ||
25 | 57 | try: | 58 | try: |
26 | @@ -111,7 +112,7 @@ def load_yaml_as_dict(filename): | |||
27 | 111 | return d | 112 | return d |
28 | 112 | 113 | ||
29 | 113 | 114 | ||
31 | 114 | def add_global_options(parser) -> None: | 115 | def add_global_options(parser: argparse.ArgumentParser) -> None: |
32 | 115 | """Adds arguments to the given parser for generic options. | 116 | """Adds arguments to the given parser for generic options. |
33 | 116 | 117 | ||
34 | 117 | :param argparse.ArgumentParser parser: A parser or subparser. | 118 | :param argparse.ArgumentParser parser: A parser or subparser. |
35 | @@ -138,7 +139,7 @@ def add_global_options(parser) -> None: | |||
36 | 138 | help="Minimize output during processing") | 139 | help="Minimize output during processing") |
37 | 139 | 140 | ||
38 | 140 | 141 | ||
40 | 141 | def add_basic_config_options(parser) -> None: | 142 | def add_basic_config_options(parser: argparse.ArgumentParser) -> None: |
41 | 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. |
42 | 143 | 144 | ||
43 | 144 | The config options are supported by the 'create' and 'set' command, | 145 | The config options are supported by the 'create' and 'set' command, |
44 | @@ -146,6 +147,8 @@ def add_basic_config_options(parser) -> None: | |||
45 | 146 | 147 | ||
46 | 147 | These options represent what can be set as a user from the Launchpad | 148 | These options represent what can be set as a user from the Launchpad |
47 | 148 | web interface. | 149 | web interface. |
48 | 150 | |||
49 | 151 | :param argparse.ArgumentParser parser: A parser or subparser. | ||
50 | 149 | """ | 152 | """ |
51 | 150 | # Architectures | 153 | # Architectures |
52 | 151 | parser.add_argument( | 154 | parser.add_argument( |
53 | @@ -203,7 +206,7 @@ def add_basic_config_options(parser) -> None: | |||
54 | 203 | ) | 206 | ) |
55 | 204 | 207 | ||
56 | 205 | 208 | ||
58 | 206 | def create_arg_parser(): | 209 | def create_arg_parser() -> argparse.ArgumentParser: |
59 | 207 | """Sets up the command line parser object. | 210 | """Sets up the command line parser object. |
60 | 208 | 211 | ||
61 | 209 | :rtype: argparse.ArgumentParser | 212 | :rtype: argparse.ArgumentParser |
62 | @@ -364,7 +367,14 @@ def create_arg_parser(): | |||
63 | 364 | return parser | 367 | return parser |
64 | 365 | 368 | ||
65 | 366 | 369 | ||
67 | 367 | def create_config(lp, args): | 370 | DEFAULT_CONFIG = { |
68 | 371 | 'debug': False, | ||
69 | 372 | 'ppa_name': None, | ||
70 | 373 | 'team_name': None, | ||
71 | 374 | 'wait_seconds': 10.0 | ||
72 | 375 | } | ||
73 | 376 | |||
74 | 377 | def create_config(lp: Lp, args: argparse.Namespace) -> dict[str, Any]: | ||
75 | 368 | """Creates config object by loading from file and adding args. | 378 | """Creates config object by loading from file and adding args. |
76 | 369 | 379 | ||
77 | 370 | This routine merges the command line parameter values with data | 380 | This routine merges the command line parameter values with data |
78 | @@ -380,19 +390,13 @@ def create_config(lp, args): | |||
79 | 380 | :rtype: dict | 390 | :rtype: dict |
80 | 381 | :returns: dict of configuration parameters and values, or None on error | 391 | :returns: dict of configuration parameters and values, or None on error |
81 | 382 | """ | 392 | """ |
82 | 383 | DEFAULT_CONFIG = { | ||
83 | 384 | 'debug': False, | ||
84 | 385 | 'ppa_name': None, | ||
85 | 386 | 'team_name': None, | ||
86 | 387 | 'wait_seconds': 10.0 | ||
87 | 388 | } | ||
88 | 389 | config_path = os.path.expanduser(args.config_filename) | 393 | config_path = os.path.expanduser(args.config_filename) |
89 | 390 | try: | 394 | try: |
90 | 391 | config = load_yaml_as_dict(config_path) | 395 | config = load_yaml_as_dict(config_path) |
91 | 392 | except FileNotFoundError: | 396 | except FileNotFoundError: |
92 | 393 | # Assume defaults | 397 | # Assume defaults |
93 | 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)) |
95 | 395 | config = DEFAULT_CONFIG | 399 | config = dict(DEFAULT_CONFIG) |
96 | 396 | 400 | ||
97 | 397 | # Map all non-empty elements from argparse Namespace into config dict | 401 | # Map all non-empty elements from argparse Namespace into config dict |
98 | 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}) |
99 | @@ -404,10 +408,13 @@ def create_config(lp, args): | |||
100 | 404 | lp_username = None | 408 | lp_username = None |
101 | 405 | if lp.me: | 409 | if lp.me: |
102 | 406 | lp_username = lp.me.name | 410 | lp_username = lp.me.name |
103 | 411 | if not hasattr(args, 'ppa_name'): | ||
104 | 412 | warn("No ppa name given") | ||
105 | 413 | return None | ||
106 | 414 | |||
107 | 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) |
108 | 408 | if not config['team_name'] or not config['ppa_name']: | 416 | if not config['team_name'] or not config['ppa_name']: |
111 | 409 | warn("Invalid ppa name '{}'".format(args.ppa_name)) | 417 | raise ValueError("Invalid ppa name '{}'".format(args.ppa_name)) |
110 | 410 | return None | ||
112 | 411 | 418 | ||
113 | 412 | if args.dry_run: | 419 | if args.dry_run: |
114 | 413 | config['dry_run'] = True | 420 | config['dry_run'] = True |
115 | @@ -877,7 +884,7 @@ COMMANDS = { | |||
116 | 877 | } | 884 | } |
117 | 878 | 885 | ||
118 | 879 | 886 | ||
120 | 880 | def main(args): | 887 | def main(args: argparse.Namespace) -> int: |
121 | 881 | """Main entrypoint for the command. | 888 | """Main entrypoint for the command. |
122 | 882 | 889 | ||
123 | 883 | :param argparse.Namespace args: Command line arguments. | 890 | :param argparse.Namespace args: Command line arguments. |
124 | diff --git a/tests/helpers.py b/tests/helpers.py | |||
125 | index c6a55eb..07ad462 100644 | |||
126 | --- a/tests/helpers.py | |||
127 | +++ b/tests/helpers.py | |||
128 | @@ -15,18 +15,32 @@ sys.path.insert(0, os.path.realpath( | |||
129 | 15 | os.path.join(os.path.dirname(os.path.realpath(__file__)), ".."))) | 15 | os.path.join(os.path.dirname(os.path.realpath(__file__)), ".."))) |
130 | 16 | 16 | ||
131 | 17 | from ppa.ppa import Ppa | 17 | from ppa.ppa import Ppa |
132 | 18 | from ppa.constants import ARCHES_PPA_DEFAULT | ||
133 | 18 | from ppa.ppa_group import PpaAlreadyExists | 19 | from ppa.ppa_group import PpaAlreadyExists |
134 | 19 | 20 | ||
135 | 20 | 21 | ||
136 | 22 | class ProcessorMock: | ||
137 | 23 | """A stand-in for a Launchpad Processor object.""" | ||
138 | 24 | def __init__(self, name): | ||
139 | 25 | self.name = name | ||
140 | 26 | |||
141 | 27 | |||
142 | 21 | class ArchiveMock: | 28 | class ArchiveMock: |
143 | 22 | """A stand-in for a Launchpad Archive object.""" | 29 | """A stand-in for a Launchpad Archive object.""" |
144 | 23 | def __init__(self, name, description): | 30 | def __init__(self, name, description): |
145 | 24 | self.displayname = name | 31 | self.displayname = name |
146 | 25 | self.description = description | 32 | self.description = description |
147 | 33 | self.processors = [ProcessorMock(proc_name) for proc_name in ARCHES_PPA_DEFAULT] | ||
148 | 26 | 34 | ||
149 | 27 | def lp_save(self): | 35 | def lp_save(self): |
150 | 28 | return True | 36 | return True |
151 | 29 | 37 | ||
152 | 38 | def setProcessors(self, processors): | ||
153 | 39 | self.processors = [] | ||
154 | 40 | for proc in processors: | ||
155 | 41 | proc_name = proc.split('/')[-1] | ||
156 | 42 | self.processors.append(ProcessorMock(proc_name)) | ||
157 | 43 | |||
158 | 30 | 44 | ||
159 | 31 | class PersonMock: | 45 | class PersonMock: |
160 | 32 | """A stand-in for a Launchpad Person object.""" | 46 | """A stand-in for a Launchpad Person object.""" |
161 | @@ -38,14 +52,13 @@ class PersonMock: | |||
162 | 38 | for ppa in self._ppas: | 52 | for ppa in self._ppas: |
163 | 39 | if ppa.name == name: | 53 | if ppa.name == name: |
164 | 40 | raise PpaAlreadyExists(name) | 54 | raise PpaAlreadyExists(name) |
167 | 41 | new_ppa = Ppa(name, self.name, description) | 55 | self._ppas.append(Ppa(name, self.name, description)) |
166 | 42 | self._ppas.append(new_ppa) | ||
168 | 43 | return True | 56 | return True |
169 | 44 | 57 | ||
170 | 45 | def getPPAByName(self, name): | 58 | def getPPAByName(self, name): |
171 | 46 | for ppa in self._ppas: | 59 | for ppa in self._ppas: |
172 | 47 | if ppa.name == name: | 60 | if ppa.name == name: |
174 | 48 | return ArchiveMock(name, ppa.description) | 61 | return ArchiveMock(ppa.name, ppa.description) |
175 | 49 | return None | 62 | return None |
176 | 50 | 63 | ||
177 | 51 | def lp_save(self): | 64 | def lp_save(self): |
178 | diff --git a/tests/test_scripts_ppa.py b/tests/test_scripts_ppa.py | |||
179 | index 10acc30..40c7714 100644 | |||
180 | --- a/tests/test_scripts_ppa.py | |||
181 | +++ b/tests/test_scripts_ppa.py | |||
182 | @@ -226,7 +226,7 @@ def test_create_arg_parser_basic_config(command): | |||
183 | 226 | assert args.description == 'x' | 226 | assert args.description == 'x' |
184 | 227 | args.description = None | 227 | args.description = None |
185 | 228 | 228 | ||
187 | 229 | # Check --enable / -D|--disable | 229 | # Check --enable / --disable |
188 | 230 | args = parser.parse_args([command, 'test-ppa', '--enable']) | 230 | args = parser.parse_args([command, 'test-ppa', '--enable']) |
189 | 231 | assert args.set_enabled is True | 231 | assert args.set_enabled is True |
190 | 232 | args.set_enabled = False | 232 | args.set_enabled = False |
191 | @@ -355,27 +355,75 @@ def test_create_arg_parser_tests(): | |||
192 | 355 | args.show_urls = None | 355 | args.show_urls = None |
193 | 356 | 356 | ||
194 | 357 | 357 | ||
200 | 358 | @pytest.mark.xfail(reason="Unimplemented") | 358 | @pytest.mark.parametrize('command_line_options, expected_config', [ |
201 | 359 | def test_create_config(): | 359 | pytest.param([], {}), |
202 | 360 | # args = [] | 360 | (['status', 'ppa:aa/bb'], {'command': 'status', 'team_name': 'aa', 'ppa_name': 'bb'}), |
203 | 361 | # TODO: Set config_filename, ppa_name, team_name in args | 361 | (['status', 'aa/bb'], {'team_name': 'aa', 'ppa_name': 'bb'}), |
204 | 362 | # config = script.create_config(args) | 362 | (['status', 'bb'], {'team_name': 'me', 'ppa_name': 'bb'}), |
205 | 363 | (['--debug', 'status', 'ppa:aa/bb'], {'debug': True}), | ||
206 | 364 | (['--dry-run', 'status', 'ppa:aa/bb'], {'dry_run': True}), | ||
207 | 365 | (['--verbose', 'status', 'ppa:aa/bb'], {'verbose': True}), | ||
208 | 366 | (['--quiet', 'status', 'ppa:aa/bb'], {'quiet': True}), | ||
209 | 367 | ]) | ||
210 | 368 | def test_create_config_from_args(command_line_options, expected_config): | ||
211 | 369 | '''Checks creation of a config object from an argparser object. | ||
212 | 370 | |||
213 | 371 | Prior tests cover the creation of proper args from the command line; | ||
214 | 372 | this test relies on the already-tested argparse machinery to create | ||
215 | 373 | various args to pass to create_config() in order to assure that the | ||
216 | 374 | right config dict is generated in response. | ||
217 | 375 | ''' | ||
218 | 376 | lp = LpServiceMock() | ||
219 | 377 | parser = script.create_arg_parser() | ||
220 | 378 | args = parser.parse_args(command_line_options) | ||
221 | 379 | config = script.create_config(lp, args) | ||
222 | 380 | |||
223 | 381 | expected_result = script.DEFAULT_CONFIG | ||
224 | 382 | for key, value in expected_config.items(): | ||
225 | 383 | assert key in config.keys() | ||
226 | 384 | assert config[key] == value | ||
227 | 385 | |||
228 | 386 | |||
229 | 387 | @pytest.mark.parametrize('args, expected_exception', [ | ||
230 | 388 | # Bad command | ||
231 | 389 | ([None], SystemExit), | ||
232 | 390 | ([''], SystemExit), | ||
233 | 391 | (['INVALID'], SystemExit), | ||
234 | 392 | ([1], TypeError), | ||
235 | 393 | |||
236 | 394 | # Bad ppa name | ||
237 | 395 | (['status'], SystemExit), | ||
238 | 396 | (['status', None], ValueError), | ||
239 | 397 | (['status', ''], ValueError), | ||
240 | 398 | (['status', 'INVALID'], ValueError), | ||
241 | 399 | (['status', 1], TypeError), | ||
242 | 400 | |||
243 | 401 | # Bad argument name | ||
244 | 402 | (['--invalid', 'status', 'ppa:aa/bb'], SystemExit), | ||
245 | 403 | ]) | ||
246 | 404 | def test_create_config_from_args_error(args, expected_exception): | ||
247 | 405 | '''Checks creation of a config object from an argparser object.''' | ||
248 | 406 | lp = LpServiceMock() | ||
249 | 407 | parser = script.create_arg_parser() | ||
250 | 363 | 408 | ||
252 | 364 | pass | 409 | with pytest.raises(expected_exception): |
253 | 410 | args = parser.parse_args(args) | ||
254 | 411 | script.create_config(lp, args) | ||
255 | 365 | 412 | ||
256 | 366 | 413 | ||
257 | 367 | def test_command_create(fake_config, monkeypatch): | 414 | def test_command_create(fake_config, monkeypatch): |
258 | 368 | lp = LpServiceMock() | 415 | lp = LpServiceMock() |
259 | 369 | monkeypatch.setattr("sys.stdin", io.StringIO('test description')) | 416 | monkeypatch.setattr("sys.stdin", io.StringIO('test description')) |
260 | 370 | assert script.command_create(lp, fake_config) == 0 | ||
261 | 371 | 417 | ||
262 | 418 | assert script.command_create(lp, fake_config) == 0 | ||
263 | 372 | the_ppa = lp.me.getPPAByName(fake_config['ppa_name']) | 419 | the_ppa = lp.me.getPPAByName(fake_config['ppa_name']) |
264 | 420 | assert the_ppa | ||
265 | 373 | 421 | ||
266 | 374 | # Check basic attributes (further coverage is in test_ppa_group.py) | 422 | # Check basic attributes (further coverage is in test_ppa_group.py) |
267 | 375 | assert the_ppa.displayname == fake_config['ppa_name'] | 423 | assert the_ppa.displayname == fake_config['ppa_name'] |
268 | 376 | assert the_ppa.description == 'test description' | 424 | assert the_ppa.description == 'test description' |
271 | 377 | 425 | assert the_ppa.processors | |
272 | 378 | # TODO: Check processors | 426 | assert type(the_ppa.processors) is list |
273 | 379 | 427 | ||
274 | 380 | 428 | ||
275 | 381 | @pytest.mark.xfail(reason="Unimplemented") | 429 | @pytest.mark.xfail(reason="Unimplemented") |
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.