1# Copyright 2013-2018 The Distro Tracker Developers 

2# See the COPYRIGHT file at the top-level directory of this distribution and 

3# at https://deb.li/DTAuthors 

4# 

5# This file is part of Distro Tracker. It is subject to the license terms 

6# in the LICENSE file found in the top-level directory of this 

7# distribution and at https://deb.li/DTLicense. No part of Distro Tracker, 

8# including this file, may be copied, modified, propagated, or distributed 

9# except according to the terms contained in the LICENSE file. 

10"""Utilities for processing Debian package information.""" 

11import os 

12import re 

13import shutil 

14import subprocess 

15import tarfile 

16 

17import apt 

18 

19import apt_pkg 

20 

21from debian import deb822 

22 

23from django.conf import settings 

24from django.urls import reverse 

25from django.utils.encoding import force_bytes 

26 

27from distro_tracker.core.utils.email_messages import \ 

28 name_and_address_from_string as parse_address 

29from distro_tracker.core.utils.email_messages import \ 

30 names_and_addresses_from_string as parse_addresses 

31 

32 

33def package_hashdir(package_name): 

34 """ 

35 Returns the name of the hash directory used to avoid having too 

36 many entries in a single directory. It's usually the first letter 

37 of the package except for lib* packages where it's the first 4 

38 letters. 

39 

40 :param package_name: The package name. 

41 :type package_name: str 

42 

43 :returns: Name of the hash directory. 

44 :rtype: str 

45 """ 

46 if package_name is None: 

47 return None 

48 if package_name.startswith('lib'): 

49 return package_name[0:4] 

50 else: 

51 return package_name[0:1] 

52 

53 

54def package_url(package_name): 

55 """ 

56 Returns the URL of the page dedicated to this package name. 

57 

58 :param package_name: The package name. 

59 :type package_name: str or PackageName model 

60 

61 :returns: Name of the hash directory. 

62 :rtype: str 

63 """ 

64 if package_name is None: 

65 return None 

66 return reverse('dtracker-package-page', 

67 kwargs={'package_name': str(package_name)}) 

68 

69 

70def extract_vcs_information(stanza): 

71 """ 

72 Extracts the VCS information from a package's Sources entry. 

73 

74 :param stanza: The ``Sources`` entry from which to extract the VCS info. 

75 Maps ``Sources`` key names to values. 

76 :type stanza: dict 

77 

78 :returns: VCS information regarding the package. Contains the following 

79 keys: type[, browser, url, branch] 

80 :rtype: dict 

81 """ 

82 vcs = {} 

83 for key, value in stanza.items(): 

84 key = key.lower() 

85 if key == 'vcs-browser': 

86 vcs['browser'] = value 

87 elif key.startswith('vcs-'): 

88 vcs['type'] = key[4:] 

89 vcs['url'] = value 

90 if vcs['type'] == 'git': 

91 match = re.match(r'(?P<url>.*?)\s+-b\s*(?P<branch>\S+)', value) 

92 if match: 

93 vcs['url'] = match.group('url') 

94 vcs['branch'] = match.group('branch') 

95 return vcs 

96 

97 

98def extract_dsc_file_name(stanza): 

99 """ 

100 Extracts the name of the .dsc file from a package's Sources entry. 

101 

102 :param stanza: The ``Sources`` entry from which to extract the VCS info. 

103 Maps ``Sources`` key names to values. 

104 :type stanza: dict 

105 

106 """ 

107 for field in ('checksums-sha256', 'checksums-sha1', 'files'): 

108 for entry in stanza.get(field, []): 

109 if entry.get('name', '').endswith('.dsc'): 109 ↛ 108line 109 didn't jump to line 108, because the condition on line 109 was never false

110 return entry['name'] 

111 

112 return None 

113 

114 

115def extract_information_from_sources_entry(stanza): 

116 """ 

117 Extracts information from a ``Sources`` file entry and returns it in the 

118 form of a dictionary. 

119 

120 :param stanza: The raw entry's key-value pairs. 

121 :type stanza: Case-insensitive dict 

122 """ 

123 binaries = [ 

124 binary.strip() 

125 for binary in stanza['binary'].split(',') 

126 ] 

127 entry = { 

128 'version': stanza['version'], 

129 'homepage': stanza.get('homepage', ''), 

130 'priority': stanza.get('priority', ''), 

131 'section': stanza.get('section', ''), 

132 'architectures': stanza['architecture'].split(), 

133 'binary_packages': binaries, 

134 'maintainer': parse_address(stanza['maintainer']), 

135 'uploaders': parse_addresses(stanza.get('uploaders', '')), 

136 'standards_version': stanza.get('standards-version', ''), 

137 'vcs': extract_vcs_information(stanza), 

138 'dsc_file_name': extract_dsc_file_name(stanza), 

139 'directory': stanza.get('directory', ''), 

140 } 

141 

142 return entry 

143 

144 

145def extract_information_from_packages_entry(stanza): 

146 """ 

147 Extracts information from a ``Packages`` file entry and returns it in the 

148 form of a dictionary. 

149 

150 :param stanza: The raw entry's key-value pairs. 

151 :type stanza: Case-insensitive dict 

152 """ 

153 entry = { 

154 'version': stanza['version'], 

155 'short_description': stanza.get('description', '')[:300], 

156 } 

157 

158 return entry 

159 

160 

161class SourcePackageRetrieveError(Exception): 

162 pass 

163 

164 

165class AptCache(object): 

166 """ 

167 A class for handling cached package information. 

168 """ 

169 DEFAULT_MAX_SIZE = 1 * 1024 ** 3 # 1 GiB 

170 QUILT_FORMAT = '3.0 (quilt)' 

171 

172 class AcquireProgress(apt.progress.base.AcquireProgress): 

173 """ 

174 Instances of this class can be passed to :meth:`apt.cache.Cache.update` 

175 calls. 

176 It provides a way to track which files were changed and which were not 

177 by an update operation. 

178 """ 

179 def __init__(self, *args, **kwargs): 

180 super(AptCache.AcquireProgress, self).__init__(*args, **kwargs) 

181 self.fetched = [] 

182 self.hit = [] 

183 

184 def done(self, item): 

185 self.fetched.append(os.path.split(item.owner.destfile)[1]) 

186 

187 def ims_hit(self, item): 

188 self.hit.append(os.path.split(item.owner.destfile)[1]) 

189 

190 def pulse(self, owner): 

191 return True 

192 

193 def __init__(self): 

194 # The root cache directory is a subdirectory in the 

195 # DISTRO_TRACKER_CACHE_DIRECTORY 

196 self.cache_root_dir = os.path.join( 

197 settings.DISTRO_TRACKER_CACHE_DIRECTORY, 

198 'apt-cache' 

199 ) 

200 self.sources_list_path = os.path.join( 

201 self.cache_root_dir, 'etc', 'sources.list') 

202 self.conf_file_path = os.path.join(self.cache_root_dir, 

203 'etc', 'apt.conf') 

204 os.environ['APT_CONFIG'] = self.conf_file_path 

205 

206 self.sources = [] 

207 self.packages = [] 

208 self.cache_max_size = getattr( 

209 settings, 'DISTRO_TRACKER_APT_CACHE_MAX_SIZE', 

210 self.DEFAULT_MAX_SIZE) 

211 #: The directory where source package files are cached 

212 self.source_cache_directory = os.path.join(self.cache_root_dir, 

213 'packages') 

214 self._cache_size = None # Evaluate the cache size lazily 

215 

216 self.configure_cache() 

217 

218 @property 

219 def cache_size(self): 

220 if self._cache_size is None: 

221 self._cache_size = \ 

222 self.get_directory_size(self.source_cache_directory) 

223 return self._cache_size 

224 

225 def get_directory_size(self, directory_path): 

226 """ 

227 Returns the total space taken by the given directory in bytes. 

228 

229 :param directory_path: The path to the directory 

230 :type directory_path: string 

231 

232 :rtype: int 

233 """ 

234 # Convert the directory path to bytes to make sure all os calls deal 

235 # with bytes, not unicode objects. 

236 # This way any file names with invalid utf-8 names, are correctly 

237 # handled, without causing an error. 

238 directory_path = force_bytes(directory_path) 

239 total_size = 0 

240 for dirpath, dirnames, filenames in os.walk(directory_path): 

241 for file_name in filenames: 

242 file_path = os.path.join(dirpath, file_name) 

243 stat = os.lstat(file_path) 

244 total_size += stat.st_size 

245 

246 return total_size 

247 

248 def clear_cache(self): 

249 """ 

250 Removes all cache information. This causes the next update to retrieve 

251 fresh repository files. 

252 """ 

253 self._remove_dir(self.cache_root_dir) 

254 self.configure_cache() 

255 

256 def update_sources_list(self): 

257 """ 

258 Updates the ``sources.list`` file used to list repositories for which 

259 package information should be cached. 

260 """ 

261 from distro_tracker.core.models import Repository 

262 

263 directory = os.path.dirname(self.sources_list_path) 

264 if not os.path.exists(directory): 

265 os.makedirs(directory) 

266 

267 with open(self.sources_list_path, 'w') as sources_list: 

268 for repository in Repository.objects.all(): 

269 sources_list.write(repository.sources_list_entry + '\n') 

270 

271 def update_apt_conf(self): 

272 """ 

273 Updates the ``apt.conf`` file which gives general settings for the 

274 :class:`apt.cache.Cache`. 

275 

276 In particular, this updates the list of all architectures which should 

277 be considered in package updates based on architectures that the 

278 repositories support. 

279 """ 

280 from distro_tracker.core.models import Architecture 

281 

282 with open(self.conf_file_path, 'w') as conf_file: 

283 conf_file.write('APT::Architectures { ') 

284 for architecture in Architecture.objects.all(): 

285 conf_file.write('"{arch}"; '.format(arch=architecture)) 

286 conf_file.write('};\n') 

287 conf_file.write('Acquire::CompressionTypes::Order:: "xz";\n') 

288 conf_file.write('Dir "{}/";\n'.format(self.cache_root_dir)) 

289 conf_file.write('Dir::State "state/";\n') 

290 conf_file.write('Dir::State::status "dpkg-status";\n') 

291 conf_file.write('Dir::Etc "etc/";\n') 

292 conf_file.write('Dir::Etc::sourcelist "{src}";\n'.format( 

293 src=self.sources_list_path)) 

294 conf_file.write('Dir::Etc::Trusted "{src}";\n'.format( 

295 src=settings.DISTRO_TRACKER_TRUSTED_GPG_MAIN_FILE)) 

296 conf_file.write('Dir::Etc::TrustedParts "{src}";\n'.format( 

297 src=settings.DISTRO_TRACKER_TRUSTED_GPG_PARTS_DIR)) 

298 

299 def configure_cache(self): 

300 """ 

301 Configures the cache based on the most current repository information. 

302 """ 

303 self.update_sources_list() 

304 self.update_apt_conf() 

305 # Clean up the configuration we might have read during "import apt" 

306 for root_key in apt_pkg.config.list(): 

307 apt_pkg.config.clear(root_key) 

308 # Load the proper configuration 

309 apt_pkg.init() 

310 # Ensure we have the required directories 

311 for apt_dir in [apt_pkg.config.find_dir('Dir::State::lists'), 

312 apt_pkg.config.find_dir('Dir::Etc::sourceparts'), 

313 apt_pkg.config.find_dir('Dir::Cache::archives')]: 

314 if not os.path.exists(apt_dir): 

315 os.makedirs(apt_dir) 

316 

317 def _index_file_full_path(self, file_name): 

318 """ 

319 Returns the absolute path for the given cached index file. 

320 

321 :param file_name: The name of the cached index file. 

322 :type file_name: string 

323 

324 :rtype: string 

325 """ 

326 return os.path.join( 

327 apt_pkg.config.find_dir('Dir::State::lists'), 

328 file_name 

329 ) 

330 

331 def _match_index_file_to_repository(self, sources_file): 

332 """ 

333 Returns a two-tuple ``(class:`Repository <distro_tracker.core. 

334 models.Repository>`, component)``. The class:`Repository 

335 <distro_tracker.core.models.Repository>` instance which matches the 

336 given cached ``Sources`` file and the ``component`` of the ``Source``. 

337 

338 :rtype: (:class:`Repository <distro_tracker.core.models.Repository>`, 

339 string) 

340 """ 

341 from distro_tracker.core.models import Repository 

342 

343 sources_list = apt_pkg.SourceList() 

344 sources_list.read_main_list() 

345 component_url = None 

346 component = None 

347 for entry in sources_list.list: 

348 for index_file in entry.index_files: 

349 if os.path.basename(sources_file) in index_file.describe: 

350 base_url, component, _ = index_file.describe.split(None, 2) 

351 base_url = base_url.rstrip('/') 

352 component_url = base_url + '/' + component 

353 break 

354 

355 components = component.split('/') 

356 if len(components) >= 2: 

357 component = components[1].strip() 

358 

359 for repository in Repository.objects.all(): 

360 if component_url in repository.component_urls: 

361 return repository, component 

362 

363 def _get_all_cached_files(self): 

364 """ 

365 Returns a list of all cached files. 

366 """ 

367 lists_directory = apt_pkg.config.find_dir('Dir::State::lists') 

368 try: 

369 return [ 

370 os.path.join(lists_directory, file_name) 

371 for file_name in os.listdir(lists_directory) 

372 if os.path.isfile(os.path.join(lists_directory, file_name)) 

373 ] 

374 except OSError: 

375 # The directory structure does not exist => nothing is cached 

376 return [] 

377 

378 def get_cached_files(self, filter_function=None): 

379 """ 

380 Returns cached files, optionally filtered by the given 

381 ``filter_function`` 

382 

383 :param filter_function: Takes a file name as the only parameter and 

384 returns a :class:`bool` indicating whether it should be included 

385 in the result. 

386 :type filter_function: callable 

387 

388 :returns: A list of cached file names 

389 :rtype: list 

390 """ 

391 if filter_function is None: 391 ↛ 393line 391 didn't jump to line 393, because the condition on line 391 was never true

392 # Include all files if the filter function is not provided 

393 def filter_function(x): 

394 return True 

395 

396 return [ 

397 file_name 

398 for file_name in self._get_all_cached_files() 

399 if filter_function(file_name) 

400 ] 

401 

402 def get_sources_files_for_repository(self, repository): 

403 """ 

404 Returns all ``Sources`` files which are cached for the given 

405 repository. 

406 

407 For instance, ``Sources`` files for different suites are cached 

408 separately. 

409 

410 :param repository: The repository for which to return all cached 

411 ``Sources`` files 

412 :type repository: :class:`Repository 

413 <distro_tracker.core.models.Repository>` 

414 

415 :rtype: ``iterable`` of strings 

416 """ 

417 return self.get_cached_files( 

418 lambda file_name: ( 

419 file_name.endswith('Sources') and 

420 self._match_index_file_to_repository( 

421 file_name)[0] == repository)) 

422 

423 def get_packages_files_for_repository(self, repository): 

424 """ 

425 Returns all ``Packages`` files which are cached for the given 

426 repository. 

427 

428 For instance, ``Packages`` files for different suites are cached 

429 separately. 

430 

431 :param repository: The repository for which to return all cached 

432 ``Packages`` files 

433 :type repository: :class:`Repository 

434 <distro_tracker.core.models.Repository>` 

435 

436 :rtype: ``iterable`` of strings 

437 """ 

438 return self.get_cached_files( 

439 lambda file_name: ( 

440 file_name.endswith('Packages') and 

441 self._match_index_file_to_repository( 

442 file_name)[0] == repository)) 

443 

444 def update_repositories(self, force_download=False): 

445 """ 

446 Initiates a cache update. 

447 

448 :param force_download: If set to ``True`` causes the cache to be 

449 cleared before starting the update, thus making sure all index 

450 files are downloaded again. 

451 

452 :returns: A two-tuple ``(updated_sources, updated_packages)``. Each of 

453 the tuple's members is a list of 

454 (:class:`Repository <distro_tracker.core.models.Repository>`, 

455 ``component``, ``file_name``) tuple representing the repository 

456 which was updated, component, and the file which contains the fresh 

457 information. The file is either a ``Sources`` or a ``Packages`` 

458 file respectively. 

459 """ 

460 if force_download: 

461 self.clear_cache() 

462 

463 self.configure_cache() 

464 

465 cache = apt.Cache(rootdir=self.cache_root_dir) 

466 progress = AptCache.AcquireProgress() 

467 cache.update(progress) 

468 

469 updated_sources = [] 

470 updated_packages = [] 

471 for fetched_file in progress.fetched: 

472 if fetched_file.endswith('Sources'): 

473 dest = updated_sources 

474 elif fetched_file.endswith('Packages'): 

475 dest = updated_packages 

476 else: 

477 continue 

478 repository, component = self._match_index_file_to_repository( 

479 fetched_file) 

480 dest.append(( 

481 repository, component, self._index_file_full_path(fetched_file) 

482 )) 

483 

484 return updated_sources, updated_packages 

485 

486 def _get_format(self, record): 

487 """ 

488 Returns the Format field value of the given source package record. 

489 """ 

490 record = deb822.Deb822(record) 

491 return record['format'] 

492 

493 def _extract_quilt_package_debian_tar(self, debian_tar_path, outdir): 

494 """ 

495 Extracts the given tarball to the given output directory. 

496 """ 

497 with tarfile.open(debian_tar_path) as archive_file: 

498 archive_file.extractall(outdir) 

499 

500 def get_package_source_cache_directory(self, package_name): 

501 """ 

502 Returns the path to the directory where a particular source package is 

503 cached. 

504 

505 :param package_name: The name of the source package 

506 :type package_name: string 

507 

508 :rtype: string 

509 """ 

510 package_hash = ( 

511 package_name[0] 

512 if not package_name.startswith('lib') else 

513 package_name[:4] 

514 ) 

515 return os.path.join( 

516 self.source_cache_directory, 

517 package_hash, 

518 package_name) 

519 

520 def get_source_version_cache_directory(self, package_name, version): 

521 """ 

522 Returns the path to the directory where a particular source package 

523 version files are extracted. 

524 

525 :param package_name: The name of the source package 

526 :type package_name: string 

527 

528 :param version: The version of the source package 

529 :type version: string 

530 

531 :rtype: string 

532 """ 

533 package_dir = self.get_package_source_cache_directory(package_name) 

534 return os.path.join(package_dir, package_name + '-' + version) 

535 

536 def _remove_dir(self, directory_path): 

537 """ 

538 Removes the given directory, including any subdirectories and files. 

539 The method makes sure to correctly handle the situation where the 

540 directory contains files with names which are invalid utf-8. 

541 """ 

542 # Convert the directory path to bytes to make sure all os calls deal 

543 # with bytes, not unicode objects. 

544 # This way any file names with invalid utf-8 names, are correctly 

545 # handled, without causing an error. 

546 directory_path = force_bytes(directory_path) 

547 if os.path.exists(directory_path): 

548 shutil.rmtree(directory_path) 

549 

550 def clear_cached_sources(self): 

551 """ 

552 Clears all cached package source files. 

553 """ 

554 self._remove_dir(self.source_cache_directory) 

555 self._cache_size = self.get_directory_size(self.source_cache_directory) 

556 

557 def _get_apt_source_records(self, source_name, version): 

558 """ 

559 Returns a :class:`apt_pkg.SourceRecords` instance where the given 

560 source package is the current working record. 

561 """ 

562 apt.Cache(rootdir=self.cache_root_dir) # must be pre-created 

563 source_records = apt_pkg.SourceRecords() 

564 source_records.restart() 

565 # Find the cached record matching this source package and version 

566 found = False 

567 while source_records.lookup(source_name): 

568 if source_records.version == version: 

569 found = True 

570 break 

571 

572 if not found: 

573 # Package version does not exist in the cache 

574 raise SourcePackageRetrieveError( 

575 "Could not retrieve package {pkg} version {ver}:" 

576 " No such version found in the cache".format( 

577 pkg=source_name, ver=version)) 

578 

579 return source_records 

580 

581 def _extract_dpkg_source(self, retrieved_files, outdir): 

582 """ 

583 Uses dpkg-source to extract the source package. 

584 """ 

585 dsc_file_path = next( 

586 file_path 

587 for file_path in retrieved_files 

588 if file_path.endswith('.dsc')) 

589 dsc_file_path = os.path.abspath(dsc_file_path) 

590 outdir = os.path.abspath(outdir) 

591 subprocess.check_output(["dpkg-source", "-x", dsc_file_path, outdir], 

592 stderr=subprocess.STDOUT) 

593 

594 def _apt_acquire_package(self, 

595 source_records, 

596 dest_dir_path, 

597 debian_directory_only): 

598 """ 

599 Using :class:`apt_pkg.Acquire`, retrieves the source files for the 

600 source package described by the current source_records record. 

601 

602 :param source_records: The record describing the source package whose 

603 files should be retrieved. 

604 :type source_records: :class:`apt_pkg.Acquire` 

605 

606 :param dest_dir_path: The path to the directory where the downloaded 

607 files should be saved. 

608 :type dest_dir_path: string 

609 

610 :param debian_directory_only: A flag indicating whether only the debian 

611 directory should be downloaded. 

612 

613 :returns: A list of absolute paths of all retrieved source files. 

614 :rtype: list of strings 

615 """ 

616 package_format = self._get_format(source_records.record) 

617 # A reference to each AcquireFile instance must be kept 

618 files = [] 

619 acquire = apt_pkg.Acquire(apt.progress.base.AcquireProgress()) 

620 for md5, size, path, file_type in source_records.files: 

621 base = os.path.basename(path) 

622 dest_file_path = os.path.join(dest_dir_path, base) 

623 if debian_directory_only and package_format == self.QUILT_FORMAT: 

624 if file_type != 'diff': 

625 # Only retrieve the .debian.tar.* file for quilt packages 

626 # when only the debian directory is wanted 

627 continue 

628 files.append(apt_pkg.AcquireFile( 

629 acquire, 

630 source_records.index.archive_uri(path), 

631 md5, 

632 size, 

633 base, 

634 destfile=dest_file_path 

635 )) 

636 

637 acquire.run() 

638 

639 # Check if all items are correctly retrieved and build the list of file 

640 # paths. 

641 retrieved_paths = [] 

642 for item in acquire.items: 

643 if item.status != item.STAT_DONE: 

644 raise SourcePackageRetrieveError( 

645 'Could not retrieve file {file}: {error}'.format( 

646 file=item.destfile, 

647 error=item.error_text.decode('utf-8'))) 

648 retrieved_paths.append(item.destfile) 

649 

650 return retrieved_paths 

651 

652 def retrieve_source(self, source_name, version, 

653 debian_directory_only=False): 

654 """ 

655 Retrieve the source package files for the given source package version. 

656 

657 :param source_name: The name of the source package 

658 :type source_name: string 

659 :param version: The version of the source package 

660 :type version: string 

661 :param debian_directory_only: Flag indicating if the method should try 

662 to retrieve only the debian directory of the source package. This 

663 is usually only possible when the package format is 3.0 (quilt). 

664 :type debian_directory_only: Boolean 

665 

666 :returns: The path to the directory containing the extracted source 

667 package files. 

668 :rtype: string 

669 """ 

670 if self.cache_size > self.cache_max_size: 

671 # If the maximum allowed cache size has been exceeded, 

672 # clear the cache 

673 self.clear_cached_sources() 

674 

675 source_records = self._get_apt_source_records(source_name, version) 

676 

677 dest_dir_path = self.get_package_source_cache_directory(source_name) 

678 if not os.path.exists(dest_dir_path): 678 ↛ 681line 678 didn't jump to line 681, because the condition on line 678 was never false

679 os.makedirs(dest_dir_path) 

680 # Remember the size of the directory in the beginning 

681 old_size = self.get_directory_size(dest_dir_path) 

682 

683 # Download the source files 

684 retrieved_files = self._apt_acquire_package( 

685 source_records, dest_dir_path, debian_directory_only) 

686 

687 # Extract the retrieved source files 

688 outdir = self.get_source_version_cache_directory(source_name, version) 

689 # dpkg-source expects this directory not to exist 

690 self._remove_dir(outdir) 

691 

692 package_format = self._get_format(source_records.record) 

693 if debian_directory_only and package_format == self.QUILT_FORMAT: 693 ↛ 695line 693 didn't jump to line 695, because the condition on line 693 was never true

694 # dpkg-source cannot extract an incomplete package 

695 self._extract_quilt_package_debian_tar(retrieved_files[0], outdir) 

696 else: 

697 # Let dpkg-source handle the extraction in all other cases 

698 self._extract_dpkg_source(retrieved_files, outdir) 

699 

700 # Update the current cache size based on the changes made by getting 

701 # this source package. 

702 new_size = self.get_directory_size(dest_dir_path) 

703 size_delta = new_size - old_size 

704 self._cache_size += size_delta 

705 

706 return outdir 

707 

708 

709def html_package_list(packages): 

710 """Return a HTML-formatted list of packages.""" 

711 packages_html = [] 

712 for package in packages: 

713 if "/" in package: 

714 (source_package_name, remain) = package.split("/", 1) 

715 remain = "/%s" % (remain,) 

716 else: 

717 (source_package_name, remain) = (package, "") 

718 html = '<a href="{}">{}</a>{}'.format( 

719 package_url(source_package_name), source_package_name, remain) 

720 packages_html.append(html) 

721 

722 return ', '.join(packages_html)