1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762
|
#!/usr/bin/env python3
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
# Author: Bryce Harrington <bryce@canonical.com>
#
# Copyright (C) 2019 Bryce W. Harrington
#
# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
# more information.
"""A wrapper around a Launchpad Personal Package Archive object."""
import os
import re
import sys
import enum
from datetime import datetime
from functools import lru_cache
from itertools import chain
from typing import Iterator, List
from lazr.restfulclient.errors import BadRequest, NotFound, Unauthorized
from .constants import URL_AUTOPKGTEST
from .io import open_url
from .job import Job, get_waiting, get_running
from .result import get_results
class PendingReason(enum.Enum):
"""This describes the reason that leads an operation to hang"""
BUILD_FAILED = enum.auto() # Build failed
BUILD_WAITING = enum.auto() # Build is still ongoing
BUILD_PUB_WAITING = enum.auto() # Build is awaiting publication
SOURCE_PUB_WAITING = enum.auto() # Source is awaiting publication
BUILD_MISSING = enum.auto() # Build was expected, but missing
class PpaNotFoundError(Exception):
"""Exception indicating a requested PPA could not be found."""
def __init__(self, ppa_name, owner_name, message=None):
"""Initialize the exception object.
:param str ppa_name: The name of the missing PPA.
:param str owner_name: The person or team the PPA belongs to.
:param str message: An error message.
"""
self.ppa_name = ppa_name
self.owner_name = owner_name
self.message = message
def __str__(self):
"""Return a human-readable error message.
:rtype str:
:return: Error message about the failure.
"""
if self.message:
return self.message
return f"The PPA '{self.ppa_name}' does not exist for person or team '{self.owner_name}'"
class Ppa:
"""Encapsulate data needed to access and conveniently wrap a PPA.
This object proxies a PPA, allowing lazy initialization and caching
of data from the remote.
"""
BUILD_FAILED_FORMAT = (
" - {package_name} ({version}) {arch} {state}\n"
" + Log Link: {log_link}\n"
" + Launchpad Build Page: {build_page}\n"
)
def __init__(self, ppa_name, owner_name, ppa_description=None, service=None):
"""Initialize a new Ppa object for a given PPA.
This creates only the local representation of the PPA, it does
not cause a new PPA to be created in Launchpad. For that, see
PpaGroup.create()
:param str ppa_name: The name of the PPA within the owning
person or team's namespace.
:param str owner_name: The name of the person or team the PPA
belongs to.
:param str ppa_description: Optional description text for the PPA.
:param launchpadlib.service service: The Launchpad service object.
"""
if not ppa_name:
raise ValueError("undefined ppa_name.")
if not owner_name:
raise ValueError("undefined owner_name.")
self.ppa_name = ppa_name
self.owner_name = owner_name
if ppa_description is None:
self.ppa_description = ''
else:
self.ppa_description = ppa_description
self._service = service
def __repr__(self) -> str:
"""Return a machine-parsable unique representation of object.
:rtype: str
:returns: Official string representation of the object.
"""
return (f'{self.__class__.__name__}('
f'ppa_name={self.ppa_name!r}, owner_name={self.owner_name!r})')
def __str__(self) -> str:
"""Return a displayable string identifying the PPA.
:rtype: str
:returns: Displayable representation of the PPA.
"""
return f"{self.owner_name}/{self.name}"
@property
@lru_cache
def archive(self):
"""Retrieve the LP Archive object from the Launchpad service.
:rtype: archive
:returns: The Launchpad archive object.
:raises PpaNotFoundError: Raised if a PPA does not exist in Launchpad.
"""
if not self._service:
raise AttributeError("Ppa object not connected to the Launchpad service")
try:
owner = self._service.people[self.owner_name]
return owner.getPPAByName(name=self.ppa_name)
except NotFound:
raise PpaNotFoundError(self.ppa_name, self.owner_name)
@lru_cache
def exists(self) -> bool:
"""Indicate if the PPA exists in Launchpad."""
try:
self.archive
return True
except PpaNotFoundError:
return False
@property
@lru_cache
def address(self):
"""The proper identifier of the PPA.
:rtype: str
:returns: The full identification string for the PPA.
"""
return "ppa:{}/{}".format(self.owner_name, self.ppa_name)
@property
def name(self):
"""The name portion of the PPA's address.
:rtype: str
:returns: The name of the PPA.
"""
return self.ppa_name
@property
def url(self):
"""The HTTP url for the PPA in Launchpad.
:rtype: str
:returns: The url of the PPA.
"""
return self.archive.web_link
@property
def description(self):
"""The description body for the PPA.
:rtype: str
:returns: The description body for the PPA.
"""
return self.ppa_description
def set_description(self, description):
"""Configure the displayed description for the PPA.
:rtype: bool
:returns: True if successfully set description, False on error.
"""
self.ppa_description = description
try:
archive = self.archive
except PpaNotFoundError as e:
print(e)
return False
archive.description = description
retval = archive.lp_save()
print("setting desc to '{}'".format(description))
print("desc is now '{}'".format(self.archive.description))
return retval and self.archive.description == description
@property
@lru_cache
def is_private(self) -> bool:
"""Indicates if the PPA is private or public.
:rtype: bool
:returns: True if the archive is private, False if public.
"""
return self.archive.private
def set_private(self, private: bool):
"""Attempts to configure the PPA as private.
Note that PPAs can't be changed to private if they ever had any
sources published, or if the owning person or team is not
permitted to hold private PPAs.
:param bool private: Whether the PPA should be private or public.
"""
if private is None:
return
self.archive.private = private
self.archive.lp_save()
@property
@lru_cache
def publish(self):
return self.archive.publish
def set_publish(self, publish: bool):
if publish is None:
return
self.archive.publish = publish
self.archive.lp_save()
@property
@lru_cache
def architectures(self) -> List[str]:
"""The architectures configured to build packages in the PPA.
:rtype: List[str]
:returns: List of architecture names, or None on error.
"""
try:
return [proc.name for proc in self.archive.processors]
except PpaNotFoundError as e:
sys.stderr.write(e)
return None
def set_architectures(self, architectures: List[str]) -> bool:
"""Configure the architectures used to build packages in the PPA.
Note that some architectures may only be available upon request
from Launchpad administrators. ppa.constants.ARCHES_PPA is a
list of standard architectures that don't require permissions.
:param List[str] architectures: List of processor architecture names
:rtype: bool
:returns: True if architectures could be set, False on error or
if no architectures were specified.
"""
if not architectures:
return False
base = self._service.API_ROOT_URL.rstrip('/')
procs = []
for arch in architectures:
procs.append(f'{base}/+processors/{arch}')
try:
self.archive.setProcessors(processors=procs)
return True
except PpaNotFoundError as e:
sys.stderr.write(e)
return False
@property
@lru_cache
def dependencies(self) -> List[str]:
"""The additional PPAs configured for building packages in this PPA.
:rtype: List[str]
:returns: List of PPA addresses
"""
ppa_addresses = []
for dep in self.archive.dependencies:
ppa_dep = dep.dependency
ppa_addresses.append(ppa_dep.reference)
return ppa_addresses
def set_dependencies(self, ppa_addresses: List[str]):
"""Configure the additional PPAs used to build packages in this PPA.
This removes any existing PPA dependencies and adds the ones
in the corresponding list. If any of these new PPAs cannot be
found, this routine bails out without changing the current set.
:param List[str] ppa_addresses: Additional PPAs to add
"""
base = self._service.API_ROOT_URL.rstrip('/')
new_ppa_deps = []
for ppa_address in ppa_addresses:
owner_name, ppa_name = ppa_address_split(ppa_address)
new_ppa_dep = f'{base}/~{owner_name}/+archive/ubuntu/{ppa_name}'
new_ppa_deps.append(new_ppa_dep)
# TODO: Remove all existing dependencies
# for ppa_dep in self.archive.dependencies:
# the_ppa.removeArchiveDependency(ppa_dep)
# TODO: Not sure what to pass here, maybe a string ala 'main'?
component = None
# TODO: Allow setting alternate pockets
# TODO: Maybe for convenience it should be same as what's set for main archive?
pocket = 'Release'
for ppa_dep in new_ppa_deps:
self.archive.addArchiveDependency(
component=component,
dependency=ppa_dep,
pocket=pocket)
# TODO: Error checking
# This can throw ArchiveDependencyError if the ppa_address does not fit the_ppa
def get_binaries(
self, distro=None, series=None, arch=None, created_since_date=None,
name=None
):
"""Retrieve the binary packages available in the PPA.
:param distribution distro: The Launchpad distribution object.
:param str series: The distro's codename for the series.
:param str arch: The hardware architecture.
:param datetime created_since_date: Only return binaries that
were created on or after this date.
:param str name: Only return binaries with this name.
:rtype: List[binary_package_publishing_history]
:returns: List of binaries, or None on error
"""
if distro is None and series is None and arch is None:
try:
return chain(
self.archive.getPublishedBinaries(
created_since_date=created_since_date, status="Pending",
binary_name=name),
self.archive.getPublishedBinaries(
created_since_date=created_since_date, status="Published",
binary_name=name))
except PpaNotFoundError as e:
print(e)
return None
# elif series:
# das = get_das(distro, series, arch)
# ds = distro.getSeries(name_or_version=series)
print("Unimplemented")
return []
def get_source_publications(
self, distro=None, series=None, arch=None, created_since_date=None,
name=None
):
"""Retrieve the source packages in the PPA.
:param distribution distro: The Launchpad distribution object.
:param str series: The distro codename for the series.
:param str arch: The hardware architecture.
:param datetime created_since_date: Only return source publications that
were created on or after this date.
:param str name: Only return publications for this source package.
:rtype: iterator
:returns: Collection of source publications, or None on error.
"""
if distro and series and arch:
# das = get_das(distro, series, arch)
# ds = distro.getSeries(name_or_version=series)
print("Unimplemented")
return None
try:
return chain(
self.archive.getPublishedSources(
created_since_date=created_since_date,
status="Pending",
source_name=name),
self.archive.getPublishedSources(
created_since_date=created_since_date,
status="Published",
source_name=name))
except PpaNotFoundError as e:
print(e)
return None
return None
def destroy(self):
"""Delete the PPA.
:rtype: bool
:returns: True if PPA was successfully deleted, is in process of
being deleted, no longer exists, or didn't exist to begin with.
False if the PPA could not be deleted for some reason and is
still existing.
"""
try:
return self.archive.lp_delete()
except PpaNotFoundError as e:
print(e)
return True
except BadRequest:
# Will report 'Archive already deleted' if deleted but not yet gone
# we can treat this as successfully destroyed
return True
def has_packages(self, created_since_date=None, name=None) -> bool:
"""Indicate whether the PPA has any source packages.
:param created_since_date: Cutoff date for the search, None means no cutoff.
:param name: Only return source packages with this name.
:rtype: bool
:returns: True if PPA contains packages, False if empty or doesn't exit.
"""
return any(self.archive.getPublishedSources(
created_since_date=created_since_date,
source_name=name
))
def pending_publications(
self,
created_since_date: 'datetime | None' = None,
name: 'str | None' = None,
logging: 'bool' = False
) -> 'List[PendingReason]':
"""
Check for pending publications and returns a list of PendingReason.
:param datetime created_since_date: Cutoff date for the search, None means no cutoff
:param str name: Only return pending publications for this source package.
:rtype: list[PendingReason]
:returns: A list of PendingReason indicating the status of the
pending publications. Empty means there are no pending
publications.
"""
pending_publication_sources = {}
required_builds = {}
pending_publication_builds = {}
published_builds = {}
for source_publication in self.get_source_publications(
created_since_date=created_since_date,
name=name
):
if not source_publication.date_published:
pending_publication_sources[source_publication.self_link] = source_publication
# iterate over the getBuilds result with no status restriction to get build records
for build in source_publication.getBuilds():
required_builds[build.self_link] = build
for binary_publication in self.get_binaries(
created_since_date=created_since_date,
name=name
):
# Ignore failed builds
build = binary_publication.build
if build.buildstate != "Successfully built":
continue
# Skip binaries for obsolete sources
source_publication = build.current_source_publication
if source_publication is None:
continue
if binary_publication.status == "Pending":
pending_publication_builds[binary_publication.build_link] = binary_publication
elif binary_publication.status == "Published":
published_builds[binary_publication.build_link] = binary_publication
if not logging:
os.system('clear')
retval = []
num_builds_waiting = (
len(required_builds) - len(pending_publication_builds) - len(published_builds)
)
if num_builds_waiting != 0:
num_build_failures = 0
builds_waiting_output = ''
builds_failed_output = ''
for build in required_builds.values():
if build.buildstate == "Successfully built":
continue
elif build.buildstate == "Cancelled build":
continue
elif build.buildstate == "Failed to build":
num_build_failures += 1
builds_failed_output += self.BUILD_FAILED_FORMAT.format(
package_name=build.source_package_name,
version=build.source_package_version,
arch=build.arch_tag,
state=build.buildstate,
log_link=build.build_log_url,
build_page=build.web_link)
else:
builds_waiting_output += " - {} ({}) {}: {}\n".format(
build.source_package_name,
build.source_package_version,
build.arch_tag,
build.buildstate)
if num_builds_waiting <= num_build_failures:
print("* Some builds have failed:")
print(builds_failed_output)
retval.append(PendingReason.BUILD_FAILED)
elif builds_waiting_output != '':
print("* Still waiting on these builds:")
print(builds_waiting_output)
retval.append(PendingReason.BUILD_WAITING)
if len(pending_publication_builds) != 0:
num = len(pending_publication_builds)
print(f"* Still waiting on {num} build publications:")
for pub in pending_publication_builds.values():
print(" - {}".format(pub.display_name))
retval.append(PendingReason.BUILD_PUB_WAITING)
if len(pending_publication_sources) != 0:
num = len(pending_publication_sources)
print(f"* Still waiting on {num} source publications:")
for pub in pending_publication_sources.values():
print(" - {}".format(pub.display_name))
retval.append(PendingReason.SOURCE_PUB_WAITING)
if ((list(required_builds.keys()).sort() != list(published_builds.keys()).sort())):
print("* Missing some builds")
retval.append(PendingReason.BUILD_MISSING)
if not retval:
print("Successfully published all builds for all architectures")
return retval
def get_autopkgtest_waiting(
self,
releases: 'List[str] | None',
sources: 'List[str] | None' = None
) -> Iterator[Job]:
"""Return iterator of queued autopkgtests for this PPA.
See get_waiting() for details
:param List[str] releases: The Ubuntu series codename(s), or None.
:param List[str] sources: Only retrieve results for these
source packages, or all if blank or None.
:rtype: Iterator[Job]
:returns: Currently waiting jobs, if any, or an empty list on error
"""
response = open_url(f"{URL_AUTOPKGTEST}/queues.json", "waiting autopkgtests")
if response:
return get_waiting(response, releases=releases, sources=sources, ppa=str(self))
return []
def get_autopkgtest_running(
self,
releases: 'List[str] | None',
sources: 'List[str] | None' = None
) -> Iterator[Job]:
"""Return iterator of queued autopkgtests for this PPA.
See get_running() for details
:param List[str] releases: The Ubuntu series codename(s), or None.
:param List[str] packages: Only retrieve results for these
source packages, or all if blank or None.
:rtype: Iterator[Job]
:returns: Currently running jobs, if any, or an empty list on error
"""
response = open_url(f"{URL_AUTOPKGTEST}/static/running.json", "running autopkgtests")
if response:
return get_running(response, releases=releases, sources=sources, ppa=str(self))
return []
def get_autopkgtest_results(
self,
releases: 'List[str] | None',
architectures: 'List[str] | None',
sources: 'List[str] | None' = None
) -> Iterator[dict]:
"""Returns iterator of results from autopkgtest runs for this PPA.
See get_results() for details
:param list[str] releases: The Ubuntu series codename(s), or None.
:param list[str] architectures: The hardware architectures.
:param list[str] sources: Only retrieve results for these
source packages, or all if blank or None.
:rtype: Iterator[dict]
:returns: Autopkgtest results, if any, or an empty list on error
"""
results = []
for release in releases:
base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"
base_results_url = base_results_fmt % (release, self.owner_name, self.name)
response = open_url(f"{base_results_url}?format=plain")
if response:
trigger_sets = {}
for result in get_results(
response=response,
base_url=base_results_url,
arches=architectures,
sources=sources):
trigger = ', '.join([str(r) for r in result.get_triggers()])
trigger_sets.setdefault(trigger, [])
trigger_sets[trigger].append(result)
results.append(trigger_sets)
return results
def ppa_address_split(ppa_address):
"""Parse an address for a PPA into its owner and name components.
:param str ppa_address: A ppa name or address.
:rtype: tuple(str, str)
:returns: The owner name and ppa name as a tuple, or (None, None) on error.
"""
owner_name = None
if not ppa_address or len(ppa_address) < 2:
return (None, None)
if ppa_address.startswith('ppa:'):
if '/' not in ppa_address:
return (None, None)
rem = ppa_address.split('ppa:', 1)[1]
owner_name = rem.split('/', 1)[0]
ppa_name = rem.split('/', 1)[1]
elif ppa_address.startswith('http'):
# Only launchpad PPA urls are supported
m = re.search(
r'https://launchpad\.net/~([^/]+)/\+archive/ubuntu/([^/]+)(?:/*|/\+[a-z]+)$',
ppa_address)
if not m:
return (None, None)
owner_name = m.group(1)
ppa_name = m.group(2)
elif '/' in ppa_address:
owner_name = ppa_address.split('/', 1)[0]
ppa_name = ppa_address.split('/', 1)[1]
else:
ppa_name = ppa_address
if owner_name is not None:
if len(owner_name) < 1:
return (None, None)
owner_name = owner_name.lower()
if (ppa_name
and not (any(x.isupper() for x in ppa_name))
and ppa_name.isascii()
and '/' not in ppa_name
and len(ppa_name) > 1):
return (owner_name, ppa_name)
return (None, None)
def get_das(distro, series_name, arch_name):
"""Retrieve the arch-series for the given distro.
:param distribution distro: The Launchpad distribution object.
:param str series_name: The distro's codename for the series.
:param str arch_name: The hardware architecture.
:rtype: distro_arch_series
:returns: A Launchpad distro_arch_series object, or None on error.
"""
if series_name is None or series_name == '':
return None
for series in distro.series:
if series.name != series_name:
continue
return series.getDistroArchSeries(archtag=arch_name)
return None
def get_ppa(lp, config):
"""Retrieve the specified PPA from Launchpad.
:param Lp lp: The Launchpad wrapper object.
:param dict config: Configuration param:value map.
:rtype: Ppa
:returns: Specified PPA as a Ppa object.
"""
return Ppa(
ppa_name=config.get('ppa_name', None),
owner_name=config.get('owner_name', None),
service=lp)
if __name__ == "__main__":
import pprint
import random
import string
from .lp import Lp
from .ppa_group import PpaGroup
pp = pprint.PrettyPrinter(indent=4)
print('##########################')
print('## Ppa class smoke test ##')
print('##########################')
print()
# pylint: disable-next=invalid-name
rndstr = str(''.join(random.choices(string.ascii_lowercase, k=6)))
dep_name = f'dependency-ppa-{rndstr}'
smoketest_ppa_name = f'test-ppa-{rndstr}'
lp = Lp('smoketest', staging=True)
ppa_group = PpaGroup(service=lp, name=lp.me.name)
dep_ppa = ppa_group.create(dep_name, ppa_description=dep_name)
the_ppa = ppa_group.create(smoketest_ppa_name, ppa_description=smoketest_ppa_name)
ppa_dependencies = [f'ppa:{lp.me.name}/{dep_name}']
try:
the_ppa.set_publish(True)
if not the_ppa.exists():
print("Error: PPA does not exist")
sys.exit(1)
the_ppa.set_description("This is a testing PPA and can be deleted")
the_ppa.set_publish(False)
the_ppa.set_architectures(["amd64", "arm64"])
the_ppa.set_dependencies(ppa_dependencies)
print()
print(f"name: {the_ppa.name}")
print(f"address: {the_ppa.address}")
print(f"str(ppa): {the_ppa}")
print(f"reference: {the_ppa.archive.reference}")
print(f"self_link: {the_ppa.archive.self_link}")
print(f"web_link: {the_ppa.archive.web_link}")
print(f"description: {the_ppa.description}")
print(f"has_packages: {the_ppa.has_packages()}")
print(f"architectures: {'/'.join(the_ppa.architectures)}")
print(f"dependencies: {','.join(the_ppa.dependencies)}")
print(f"url: {the_ppa.url}")
print()
except BadRequest as e:
print(f"Error: (BadRequest) {str(e.content.decode('utf-8'))}")
except Unauthorized as e:
print(f"Error: (Unauthorized) {e}")
# pylint: disable-next=invalid-name
answer = 'x'
while answer not in ['y', 'n']:
answer = input('Ready to cleanup (i.e. delete) temporary test PPAs? (y/n) ')
answer = answer[0].lower()
if answer == 'y':
print(" Cleaning up temporary test PPAs...")
the_ppa.destroy()
dep_ppa.destroy()
print(" ...Done")
|