Merge ppa-dev-tools:set-command-dependencies into ppa-dev-tools:main

Proposed by Bryce Harrington
Status: Merged
Merge reported by: Bryce Harrington
Merged at revision: 731e1db3e6065a5951108673437b588e689e14e8
Proposed branch: ppa-dev-tools:set-command-dependencies
Merge into: ppa-dev-tools:main
Diff against target: 472 lines (+197/-32)
5 files modified
ppa/lp.py (+10/-2)
ppa/ppa.py (+122/-4)
scripts/ppa (+36/-18)
tests/helpers.py (+5/-0)
tests/test_scripts_ppa.py (+24/-8)
Reviewer Review Type Date Requested Status
Lena Voytek (community) Approve
Canonical Server packageset reviewers Pending
Canonical Server Reporter Pending
Review via email: mp+441265@code.launchpad.net

Description of the change

This adds support for the --ppa-dependencies option to the create and set commands, to permit adding one or more PPAs for satisfying the given PPA's build dependencies.

I've also added the start of a handy smoketest for the ppa/ppa.py module to run through the basic settings. I plan to expand this to exercise more of the module but for now just tried to establish the basic structure and operation. I've sorted out making it use the 'qastaging' test instance of Launchpad to be able to validate the launchpad operations without actually impacting anything in production. Note that the data from qastaging is quite old and in fact may be missing your user account if you registered within the last few years, so I hope it works but YMMV. If not, you can flip staging off by editing the smoketest thusly:

    lp = Lp('smoketest', staging=False)

There's also a few small cleanup/refactors in separate commits.

Anyway, as usual the smoketest can be run via:

    $ python3 -m ppa.ppa

And all testing run via `pytest-3` or:

    $ make check

To post a comment you must log in.
Revision history for this message
Bryce Harrington (bryce) wrote :

Example output for the smoke test:

$ python3 -m ppa.ppa
##########################
## Ppa class smoke test ##
##########################

setting desc to 'This is a testing PPA and can be deleted'
desc is now 'This is a testing PPA and can be deleted'

name: test-ppa-dxtefi
address: ppa:bryce/test-ppa-dxtefi
str(ppa): bryce/test-ppa-dxtefi
reference: ~bryce/ubuntu/test-ppa-dxtefi
self_link: https://api.qastaging.launchpad.net/devel/~bryce/+archive/ubuntu/test-ppa-dxtefi
web_link: https://qastaging.launchpad.net/~bryce/+archive/ubuntu/test-ppa-dxtefi
description: This is a testing PPA and can be deleted
has_packages: False
architectures: amd64/arm64
dependencies: ~bryce/ubuntu/dependency-ppa-dxtefi
url: https://qastaging.launchpad.net/~bryce/+archive/ubuntu/test-ppa-dxtefi

Ready to cleanup (i.e. delete) temporary PPAs? (y/n) y
...Cleaning up test ppa...

Revision history for this message
Lena Voytek (lvoytek) wrote :

Sorry for only getting to this today, got distracted on Friday. Code looks good to me! Added a few comments for cleanup

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

Thanks for the review! One comment below, the rest of the suggestions are incorporated, I'll squash and land the branch directly.

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

Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
To git+ssh://git.launchpad.net/ppa-dev-tools
   2e6b5d7..d71ef8d main -> main

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/ppa/lp.py b/ppa/lp.py
2index 1762345..17b225a 100644
3--- a/ppa/lp.py
4+++ b/ppa/lp.py
5@@ -39,16 +39,24 @@ class Lp:
6
7 _real_instance = None
8
9- def __init__(self, application_name, service=Launchpad):
10+ def __init__(self, application_name, service=Launchpad, staging=False):
11 """Create a Launchpad service object."""
12 self._app_name = application_name
13 self._service = service
14+ if staging:
15+ self._service_root = 'qastaging'
16+ self.ROOT_URL = 'https://qastaging.launchpad.net/'
17+ self.API_ROOT_URL = 'https://api.qastaging.launchpad.net/devel/'
18+ self.BUGS_ROOT_URL = 'https://bugs.qastaging.launchpad.net/'
19+ self.CODE_ROOT_URL = 'https://code.qastaging.launchpad.net/'
20+ else:
21+ self._service_root = 'production'
22
23 def _get_instance(self):
24 """Authenticate to Launchpad."""
25 return self._service.login_with(
26 application_name=self._app_name,
27- service_root='production',
28+ service_root=self._service_root,
29 allow_access_levels=['WRITE_PRIVATE'],
30 version='devel', # Need devel for copyPackage.
31 )
32diff --git a/ppa/ppa.py b/ppa/ppa.py
33index 1536b7d..52894c4 100755
34--- a/ppa/ppa.py
35+++ b/ppa/ppa.py
36@@ -14,7 +14,7 @@ import re
37 import sys
38
39 from functools import lru_cache
40-from lazr.restfulclient.errors import BadRequest, NotFound
41+from lazr.restfulclient.errors import BadRequest, NotFound, Unauthorized
42
43
44 class PpaDoesNotExist(BaseException):
45@@ -141,7 +141,7 @@ class Ppa:
46 :rtype: str
47 :returns: The url of the PPA.
48 """
49- return "https://launchpad.net/~{}/+archive/ubuntu/{}".format(self.team_name, self.ppa_name)
50+ return self.archive.web_link
51
52 @property
53 def description(self):
54@@ -209,8 +209,10 @@ class Ppa:
55 """
56 if not architectures:
57 return False
58- uri_base = "https://api.launchpad.net/devel/+processors/{}"
59- procs = [uri_base.format(arch) for arch in architectures]
60+ base = self._service.API_ROOT_URL.rstrip('/')
61+ procs = []
62+ for arch in architectures:
63+ procs.append(f'{base}/+processors/{arch}')
64 try:
65 self.archive.setProcessors(processors=procs)
66 return True
67@@ -218,6 +220,55 @@ class Ppa:
68 sys.stderr.write(e)
69 return False
70
71+ @property
72+ @lru_cache
73+ def dependencies(self) -> list[str]:
74+ """Returns the additional PPAs configured for building packages in this PPA.
75+
76+ :rtype: list[str]
77+ :returns: List of PPA addresses
78+ """
79+ ppa_addresses = []
80+ for dep in self.archive.dependencies:
81+ ppa_dep = dep.dependency
82+ ppa_addresses.append(ppa_dep.reference)
83+ return ppa_addresses
84+
85+ def set_dependencies(self, ppa_addresses: list[str]):
86+ """Configures the additional PPAs used to build packages in this PPA.
87+
88+ This removes any existing PPA dependencies and adds the ones
89+ in the corresponding list. If any of these new PPAs cannot be
90+ found, this routine bails out without changing the current set.
91+
92+ :param list[str] ppa_addresses: Additional PPAs to add
93+ """
94+ base = self._service.API_ROOT_URL.rstrip('/')
95+ new_ppa_deps = []
96+ for ppa_address in ppa_addresses:
97+ team_name, ppa_name = ppa_address_split(ppa_address)
98+ new_ppa_dep = f'{base}/~{team_name}/+archive/ubuntu/{ppa_name}'
99+ new_ppa_deps.append(new_ppa_dep)
100+
101+ # TODO: Remove all existing dependencies
102+# for ppa_dep in self.archive.dependencies:
103+# the_ppa.removeArchiveDependency(ppa_dep)
104+
105+ # TODO: Not sure what to pass here, maybe a string ala 'main'?
106+ component = None
107+
108+ # TODO: Allow setting alternate pockets
109+ # TODO: Maybe for convenience it should be same as what's set for main archive?
110+ pocket = 'Release'
111+
112+ for ppa_dep in new_ppa_deps:
113+ self.archive.addArchiveDependency(
114+ component=component,
115+ dependency=ppa_dep,
116+ pocket=pocket)
117+ # TODO: Error checking
118+ # This can throw ArchiveDependencyError if the ppa_address does not fit the_ppa
119+
120 def get_binaries(self, distro=None, series=None, arch=None):
121 """Retrieves the binary packages available in the PPA.
122
123@@ -450,3 +501,70 @@ def get_ppa(lp, config):
124 ppa_name=config.get('ppa_name', None),
125 team_name=config.get('team_name', None),
126 service=lp)
127+
128+
129+if __name__ == "__main__":
130+ import pprint
131+ import random
132+ import string
133+ from .lp import Lp
134+ from .ppa_group import PpaGroup
135+
136+ pp = pprint.PrettyPrinter(indent=4)
137+
138+ print('##########################')
139+ print('## Ppa class smoke test ##')
140+ print('##########################')
141+ print()
142+
143+ rndstr = str(''.join(random.choices(string.ascii_lowercase, k=6)))
144+ dep_name = f'dependency-ppa-{rndstr}'
145+ smoketest_ppa_name = f'test-ppa-{rndstr}'
146+
147+ lp = Lp('smoketest', staging=True)
148+ ppa_group = PpaGroup(service=lp, name=lp.me.name)
149+
150+ dep_ppa = ppa_group.create(dep_name, ppa_description=dep_name)
151+ the_ppa = ppa_group.create(smoketest_ppa_name, ppa_description=smoketest_ppa_name)
152+ ppa_dependencies = [f'ppa:{lp.me.name}/{dep_name}']
153+
154+ try:
155+ the_ppa.set_publish(True)
156+
157+ if not the_ppa.exists():
158+ print("Error: PPA does not exist")
159+ sys.exit(1)
160+ the_ppa.set_description("This is a testing PPA and can be deleted")
161+ the_ppa.set_publish(False)
162+ the_ppa.set_architectures(["amd64", "arm64"])
163+ the_ppa.set_dependencies(ppa_dependencies)
164+
165+ print()
166+ print(f"name: {the_ppa.name}")
167+ print(f"address: {the_ppa.address}")
168+ print(f"str(ppa): {the_ppa}")
169+ print(f"reference: {the_ppa.archive.reference}")
170+ print(f"self_link: {the_ppa.archive.self_link}")
171+ print(f"web_link: {the_ppa.archive.web_link}")
172+ print(f"description: {the_ppa.description}")
173+ print(f"has_packages: {the_ppa.has_packages()}")
174+ print(f"architectures: {'/'.join(the_ppa.architectures)}")
175+ print(f"dependencies: {','.join(the_ppa.dependencies)}")
176+ print(f"url: {the_ppa.url}")
177+ print()
178+
179+ except BadRequest as e:
180+ print(f"Error: (BadRequest) {str(e.content.decode('utf-8'))}")
181+ except Unauthorized as e:
182+ print(f"Error: (Unauthorized) {e}")
183+
184+ answer = 'x'
185+ while answer not in ['y', 'n']:
186+ answer = input('Ready to cleanup (i.e. delete) temporary test PPAs? (y/n) ')
187+ answer = answer[0].lower()
188+
189+ if answer == 'y':
190+ print(" Cleaning up temporary test PPAs...")
191+ the_ppa.destroy()
192+ dep_ppa.destroy()
193+ print(" ...Done")
194diff --git a/scripts/ppa b/scripts/ppa
195index 9097004..29413ea 100755
196--- a/scripts/ppa
197+++ b/scripts/ppa
198@@ -206,6 +206,13 @@ def add_basic_config_options(parser: argparse.ArgumentParser) -> None:
199 help="Do not accept or build packages uploaded to the PPA."
200 )
201
202+ # Dependencies
203+ parser.add_argument(
204+ '--ppa-dependencies', '--ppa-depends',
205+ dest="ppa_dependencies", action='store',
206+ help="The set of other PPAs this PPA should use for satisfying build dependencies."
207+ )
208+
209 parser.add_argument(
210 '--publish',
211 dest="publish", action='store_true',
212@@ -443,11 +450,11 @@ def create_config(lp: Lp, args: argparse.Namespace) -> dict[str, Any]:
213 ### Commands ###
214 ################
215
216-def command_create(lp, config):
217+def command_create(lp: Lp, config: dict[str, str]) -> int:
218 """Creates a new PPA in Launchpad.
219
220 :param Lp lp: The Launchpad wrapper object.
221- :param dict config: Configuration param:value map.
222+ :param dict[str, str] config: Configuration param:value map.
223 :rtype: int
224 :returns: Status code OK (0) on success, non-zero on error.
225 """
226@@ -480,6 +487,12 @@ def command_create(lp, config):
227 if architectures:
228 the_ppa.set_architectures(architectures)
229 arch_str = ', '.join(the_ppa.architectures)
230+
231+ if 'ppa_dependencies' in config:
232+ # Split value on comma
233+ ppa_addresses = unpack_to_dict(config.get('ppa_dependencies'))
234+ the_ppa.set_dependencies(ppa_addresses)
235+
236 else:
237 the_ppa = Ppa(ppa_name, team_name, description)
238 arch_str = ', '.join(architectures)
239@@ -505,7 +518,7 @@ def command_create(lp, config):
240 return 1
241
242
243-def command_desc(lp, config):
244+def command_desc(lp: Lp, config: dict[str, str]) -> int:
245 """Sets the description for a PPA.
246
247 :param dict config: Configuration param:value map.
248@@ -534,11 +547,11 @@ def command_desc(lp, config):
249 return 1
250
251
252-def command_destroy(lp, config):
253+def command_destroy(lp: Lp, config: dict[str, str]) -> int:
254 """Destroys the PPA.
255
256 :param Lp lp: The Launchpad wrapper object.
257- :param dict config: Configuration param:value map.
258+ :param dict[str, str] config: Configuration param:value map.
259 :rtype: int
260 :returns: Status code OK (0) on success, non-zero on error.
261 """
262@@ -554,11 +567,11 @@ def command_destroy(lp, config):
263 return 1
264
265
266-def command_list(lp, config, filter_func=None):
267+def command_list(lp: Lp, config: dict[str, str], filter_func=None) -> int:
268 """Lists the PPAs for the user or team.
269
270 :param Lp lp: The Launchpad wrapper object.
271- :param dict config: Configuration param:value map.
272+ :param dict[str, str] config: Configuration param:value map.
273 :rtype: int
274 :returns: Status code OK (0) on success, non-zero on error.
275 """
276@@ -592,11 +605,11 @@ def command_list(lp, config, filter_func=None):
277 return 1
278
279
280-def command_exists(lp, config):
281+def command_exists(lp: Lp, config: dict[str, str]) -> int:
282 """Checks if the named PPA exists in Launchpad.
283
284 :param Lp lp: The Launchpad wrapper object.
285- :param dict config: Configuration param:value map.
286+ :param dict[str, str] config: Configuration param:value map.
287 :rtype: int
288 :returns: Status code OK (0) on success, non-zero on error.
289 """
290@@ -609,11 +622,11 @@ def command_exists(lp, config):
291 return 1
292
293
294-def command_set(lp, config):
295+def command_set(lp: Lp, config: dict[str, str]) -> int:
296 """Sets one or more properties of PPA in Launchpad.
297
298 :param Lp lp: The Launchpad wrapper object.
299- :param dict config: Configuration param:value map.
300+ :param dict[str, str] config: Configuration param:value map.
301 :rtype: int
302 :returns: Status code OK (0) on success, non-zero on error.
303 """
304@@ -632,6 +645,11 @@ def command_set(lp, config):
305 if 'displayname' in config:
306 the_ppa.archive.displayname = config['displayname']
307
308+ if 'ppa_dependencies' in config:
309+ # Split value on comma
310+ ppa_addresses = unpack_to_dict(config.get('ppa_dependencies'))
311+ the_ppa.set_dependencies(ppa_addresses)
312+
313 if 'publish' in config:
314 the_ppa.archive.publish = config.get('publish')
315
316@@ -647,7 +665,7 @@ def command_set(lp, config):
317 return 1
318
319
320-def command_show(lp, config):
321+def command_show(lp: Lp, config: dict[str, str]) -> int:
322 """Displays details about the given PPA.
323
324 :param Lp lp: The Launchpad wrapper object.
325@@ -705,11 +723,11 @@ def command_show(lp, config):
326 return 1
327
328
329-def command_status(lp, config):
330+def command_status(lp: Lp, config: dict[str, str]) -> int:
331 """Displays current status of the given ppa.
332
333 :param Lp lp: The Launchpad wrapper object.
334- :param dict config: Configuration param:value map.
335+ :param dict[str, str] config: Configuration param:value map.
336 :rtype: int
337 :returns: Status code OK (0) on success, non-zero on error.
338 """
339@@ -729,11 +747,11 @@ def command_status(lp, config):
340 return 1
341
342
343-def command_wait(lp, config):
344+def command_wait(lp: Lp, config: dict[str, str]) -> int:
345 """Polls the PPA build status and block until all builds are finished and published.
346
347 :param Lp lp: The Launchpad wrapper object.
348- :param dict config: Configuration param:value map.
349+ :param dict[str, str] config: Configuration param:value map.
350 :rtype: int
351 :returns: Status code OK (0) on success, non-zero on error.
352 """
353@@ -761,11 +779,11 @@ def command_wait(lp, config):
354 return 1
355
356
357-def command_tests(lp, config):
358+def command_tests(lp: Lp, config: dict[str, str]) -> int:
359 """Displays testing status for the PPA.
360
361 :param Lp lp: The Launchpad wrapper object.
362- :param dict config: Configuration param:value map.
363+ :param dict[str, str] config: Configuration param:value map.
364 :rtype: int
365 :returns: Status code OK (0) on success, non-zero on error.
366 """
367diff --git a/tests/helpers.py b/tests/helpers.py
368index 2329125..29fa151 100644
369--- a/tests/helpers.py
370+++ b/tests/helpers.py
371@@ -85,6 +85,11 @@ class LaunchpadMock:
372
373 class LpServiceMock:
374 """A stand-in for the Lp service object."""
375+ ROOT_URL = 'https://mocklaunchpad.net/'
376+ API_ROOT_URL = 'https://api.mocklaunchpad.net/devel/'
377+ BUGS_ROOT_URL = 'https://bugs.mocklaunchpad.net/'
378+ CODE_ROOT_URL = 'https://code.mocklaunchpad.net/'
379+
380 def __init__(self):
381 self.launchpad = LaunchpadMock()
382
383diff --git a/tests/test_scripts_ppa.py b/tests/test_scripts_ppa.py
384index a107f92..78aaeb3 100644
385--- a/tests/test_scripts_ppa.py
386+++ b/tests/test_scripts_ppa.py
387@@ -235,6 +235,14 @@ def test_create_arg_parser_basic_config(command):
388 assert args.set_disabled is True
389 args.set_disabled = False
390
391+ # Check --ppa-dependencies <PPA[,...]>
392+ args = parser.parse_args([command, 'test-ppa', '--ppa-dependencies', 'a,b,c'])
393+ assert args.ppa_dependencies == "a,b,c"
394+ args.ppa_dependencies = None
395+ args = parser.parse_args([command, 'test-ppa', '--ppa-depends', 'a,b,c'])
396+ assert args.ppa_dependencies == "a,b,c"
397+ args.ppa_dependencies = None
398+
399 # Check --publish
400 args = parser.parse_args([command, 'test-ppa', '--publish'])
401 assert args.publish is True
402@@ -477,7 +485,8 @@ def test_command_create_with_architectures(monkeypatch, fake_config, architectur
403
404 @pytest.mark.xfail(reason="Unimplemented")
405 def test_command_desc(fake_config):
406- assert script.command_desc(fake_config) == 0
407+ lp = LpServiceMock()
408+ assert script.command_desc(lp, fake_config) == 0
409 # TODO: Assert that if --dry-run specified, there are no actual
410 # changes requested of launchpad
411 # TODO: Verify the description gets set as expected
412@@ -485,29 +494,33 @@ def test_command_desc(fake_config):
413
414 @pytest.mark.xfail(reason="Unimplemented")
415 def test_command_destroy(fake_config):
416+ lp = LpServiceMock()
417 # TODO: Create a fake ppa to be destroyed
418- assert script.command_destroy(fake_config) == 0
419+ assert script.command_destroy(lp, fake_config) == 0
420 # TODO: Verify the ppa is requested to be deleted
421
422
423 @pytest.mark.xfail(reason="Unimplemented")
424 def test_command_list(fake_config):
425+ lp = LpServiceMock()
426 # TODO: Create a fake ppa with contents to be listed
427- assert script.command_list(fake_config) == 0
428+ assert script.command_list(lp, fake_config) == 0
429 # TODO: Verify the ppa addresses get listed
430
431
432 @pytest.mark.xfail(reason="Unimplemented")
433 def test_command_exists(fake_config):
434+ lp = LpServiceMock()
435 # TODO: Create fake ppa that exists
436- assert script.command_exists(fake_config) == 0
437+ assert script.command_exists(lp, fake_config) == 0
438 # TODO: Verify this returns true when the ppa does exist
439
440
441 @pytest.mark.xfail(reason="Unimplemented")
442 def test_command_not_exists(fake_config):
443+ lp = LpServiceMock()
444 # TODO: Verify this returns true when the ppa does not exist
445- assert script.command_exists(fake_config) == 1
446+ assert script.command_exists(lp, fake_config) == 1
447
448
449 @pytest.mark.parametrize('params, expected_ppa_config', [
450@@ -569,16 +582,19 @@ def test_command_set_architectures(fake_config, architectures, expected_processo
451
452 @pytest.mark.xfail(reason="Unimplemented")
453 def test_command_show(fake_config):
454- assert script.command_show(fake_config) == 0
455+ lp = LpServiceMock()
456+ assert script.command_show(lp, fake_config) == 0
457
458
459 @pytest.mark.xfail(reason="Unimplemented")
460 def test_command_status(fake_config):
461- assert script.command_status(fake_config) == 0
462+ lp = LpServiceMock()
463+ assert script.command_status(lp, fake_config) == 0
464 # TODO: Capture stdout and compare with expected
465
466
467 @pytest.mark.xfail(reason="Unimplemented")
468 def test_command_wait(fake_config):
469+ lp = LpServiceMock()
470 # TODO: Set wait period to 1 sec
471- assert script.command_wait(fake_config) == 0
472+ assert script.command_wait(lp, fake_config) == 0

Subscribers

People subscribed via source and target branches

to all changes: