Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/distlib/metadata.py: 48%

642 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-25 06:51 +0000

1# -*- coding: utf-8 -*- 

2# 

3# Copyright (C) 2012 The Python Software Foundation. 

4# See LICENSE.txt and CONTRIBUTORS.txt. 

5# 

6"""Implementation of the Metadata for Python packages PEPs. 

7 

8Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1 and 2.2). 

9""" 

10from __future__ import unicode_literals 

11 

12import codecs 

13from email import message_from_file 

14import json 

15import logging 

16import re 

17 

18 

19from . import DistlibException, __version__ 

20from .compat import StringIO, string_types, text_type 

21from .markers import interpret 

22from .util import extract_by_key, get_extras 

23from .version import get_scheme, PEP440_VERSION_RE 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class MetadataMissingError(DistlibException): 

29 """A required metadata is missing""" 

30 

31 

32class MetadataConflictError(DistlibException): 

33 """Attempt to read or write metadata fields that are conflictual.""" 

34 

35 

36class MetadataUnrecognizedVersionError(DistlibException): 

37 """Unknown metadata version number.""" 

38 

39 

40class MetadataInvalidError(DistlibException): 

41 """A metadata value is invalid""" 

42 

43# public API of this module 

44__all__ = ['Metadata', 'PKG_INFO_ENCODING', 'PKG_INFO_PREFERRED_VERSION'] 

45 

46# Encoding used for the PKG-INFO files 

47PKG_INFO_ENCODING = 'utf-8' 

48 

49# preferred version. Hopefully will be changed 

50# to 1.2 once PEP 345 is supported everywhere 

51PKG_INFO_PREFERRED_VERSION = '1.1' 

52 

53_LINE_PREFIX_1_2 = re.compile('\n \\|') 

54_LINE_PREFIX_PRE_1_2 = re.compile('\n ') 

55_241_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform', 

56 'Summary', 'Description', 

57 'Keywords', 'Home-page', 'Author', 'Author-email', 

58 'License') 

59 

60_314_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform', 

61 'Supported-Platform', 'Summary', 'Description', 

62 'Keywords', 'Home-page', 'Author', 'Author-email', 

63 'License', 'Classifier', 'Download-URL', 'Obsoletes', 

64 'Provides', 'Requires') 

65 

66_314_MARKERS = ('Obsoletes', 'Provides', 'Requires', 'Classifier', 

67 'Download-URL') 

68 

69_345_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform', 

70 'Supported-Platform', 'Summary', 'Description', 

71 'Keywords', 'Home-page', 'Author', 'Author-email', 

72 'Maintainer', 'Maintainer-email', 'License', 

73 'Classifier', 'Download-URL', 'Obsoletes-Dist', 

74 'Project-URL', 'Provides-Dist', 'Requires-Dist', 

75 'Requires-Python', 'Requires-External') 

76 

77_345_MARKERS = ('Provides-Dist', 'Requires-Dist', 'Requires-Python', 

78 'Obsoletes-Dist', 'Requires-External', 'Maintainer', 

79 'Maintainer-email', 'Project-URL') 

80 

81_426_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform', 

82 'Supported-Platform', 'Summary', 'Description', 

83 'Keywords', 'Home-page', 'Author', 'Author-email', 

84 'Maintainer', 'Maintainer-email', 'License', 

85 'Classifier', 'Download-URL', 'Obsoletes-Dist', 

86 'Project-URL', 'Provides-Dist', 'Requires-Dist', 

87 'Requires-Python', 'Requires-External', 'Private-Version', 

88 'Obsoleted-By', 'Setup-Requires-Dist', 'Extension', 

89 'Provides-Extra') 

90 

91_426_MARKERS = ('Private-Version', 'Provides-Extra', 'Obsoleted-By', 

92 'Setup-Requires-Dist', 'Extension') 

93 

94# See issue #106: Sometimes 'Requires' and 'Provides' occur wrongly in 

95# the metadata. Include them in the tuple literal below to allow them 

96# (for now). 

97# Ditto for Obsoletes - see issue #140. 

98_566_FIELDS = _426_FIELDS + ('Description-Content-Type', 

99 'Requires', 'Provides', 'Obsoletes') 

100 

101_566_MARKERS = ('Description-Content-Type',) 

102 

103_643_MARKERS = ('Dynamic', 'License-File') 

104 

105_643_FIELDS = _566_FIELDS + _643_MARKERS 

106 

107_ALL_FIELDS = set() 

108_ALL_FIELDS.update(_241_FIELDS) 

109_ALL_FIELDS.update(_314_FIELDS) 

110_ALL_FIELDS.update(_345_FIELDS) 

111_ALL_FIELDS.update(_426_FIELDS) 

112_ALL_FIELDS.update(_566_FIELDS) 

113_ALL_FIELDS.update(_643_FIELDS) 

114 

115EXTRA_RE = re.compile(r'''extra\s*==\s*("([^"]+)"|'([^']+)')''') 

116 

117 

118def _version2fieldlist(version): 

119 if version == '1.0': 

120 return _241_FIELDS 

121 elif version == '1.1': 

122 return _314_FIELDS 

123 elif version == '1.2': 

124 return _345_FIELDS 

125 elif version in ('1.3', '2.1'): 

126 # avoid adding field names if already there 

127 return _345_FIELDS + tuple(f for f in _566_FIELDS if f not in _345_FIELDS) 

128 elif version == '2.0': 

129 raise ValueError('Metadata 2.0 is withdrawn and not supported') 

130 # return _426_FIELDS 

131 elif version == '2.2': 

132 return _643_FIELDS 

133 raise MetadataUnrecognizedVersionError(version) 

134 

135 

136def _best_version(fields): 

137 """Detect the best version depending on the fields used.""" 

138 def _has_marker(keys, markers): 

139 return any(marker in keys for marker in markers) 

140 

141 keys = [key for key, value in fields.items() if value not in ([], 'UNKNOWN', None)] 

142 possible_versions = ['1.0', '1.1', '1.2', '1.3', '2.1', '2.2'] # 2.0 removed 

143 

144 # first let's try to see if a field is not part of one of the version 

145 for key in keys: 

146 if key not in _241_FIELDS and '1.0' in possible_versions: 

147 possible_versions.remove('1.0') 

148 logger.debug('Removed 1.0 due to %s', key) 

149 if key not in _314_FIELDS and '1.1' in possible_versions: 

150 possible_versions.remove('1.1') 

151 logger.debug('Removed 1.1 due to %s', key) 

152 if key not in _345_FIELDS and '1.2' in possible_versions: 

153 possible_versions.remove('1.2') 

154 logger.debug('Removed 1.2 due to %s', key) 

155 if key not in _566_FIELDS and '1.3' in possible_versions: 

156 possible_versions.remove('1.3') 

157 logger.debug('Removed 1.3 due to %s', key) 

158 if key not in _566_FIELDS and '2.1' in possible_versions: 

159 if key != 'Description': # In 2.1, description allowed after headers 

160 possible_versions.remove('2.1') 

161 logger.debug('Removed 2.1 due to %s', key) 

162 if key not in _643_FIELDS and '2.2' in possible_versions: 

163 possible_versions.remove('2.2') 

164 logger.debug('Removed 2.2 due to %s', key) 

165 # if key not in _426_FIELDS and '2.0' in possible_versions: 

166 # possible_versions.remove('2.0') 

167 # logger.debug('Removed 2.0 due to %s', key) 

168 

169 # possible_version contains qualified versions 

170 if len(possible_versions) == 1: 

171 return possible_versions[0] # found ! 

172 elif len(possible_versions) == 0: 

173 logger.debug('Out of options - unknown metadata set: %s', fields) 

174 raise MetadataConflictError('Unknown metadata set') 

175 

176 # let's see if one unique marker is found 

177 is_1_1 = '1.1' in possible_versions and _has_marker(keys, _314_MARKERS) 

178 is_1_2 = '1.2' in possible_versions and _has_marker(keys, _345_MARKERS) 

179 is_2_1 = '2.1' in possible_versions and _has_marker(keys, _566_MARKERS) 

180 # is_2_0 = '2.0' in possible_versions and _has_marker(keys, _426_MARKERS) 

181 is_2_2 = '2.2' in possible_versions and _has_marker(keys, _643_MARKERS) 

182 if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_2) > 1: 

183 raise MetadataConflictError('You used incompatible 1.1/1.2/2.1/2.2 fields') 

184 

185 # we have the choice, 1.0, or 1.2, 2.1 or 2.2 

186 # - 1.0 has a broken Summary field but works with all tools 

187 # - 1.1 is to avoid 

188 # - 1.2 fixes Summary but has little adoption 

189 # - 2.1 adds more features 

190 # - 2.2 is the latest 

191 if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_2: 

192 # we couldn't find any specific marker 

193 if PKG_INFO_PREFERRED_VERSION in possible_versions: 

194 return PKG_INFO_PREFERRED_VERSION 

195 if is_1_1: 

196 return '1.1' 

197 if is_1_2: 

198 return '1.2' 

199 if is_2_1: 

200 return '2.1' 

201 # if is_2_2: 

202 # return '2.2' 

203 

204 return '2.2' 

205 

206# This follows the rules about transforming keys as described in 

207# https://www.python.org/dev/peps/pep-0566/#id17 

208_ATTR2FIELD = { 

209 name.lower().replace("-", "_"): name for name in _ALL_FIELDS 

210} 

211_FIELD2ATTR = {field: attr for attr, field in _ATTR2FIELD.items()} 

212 

213_PREDICATE_FIELDS = ('Requires-Dist', 'Obsoletes-Dist', 'Provides-Dist') 

214_VERSIONS_FIELDS = ('Requires-Python',) 

215_VERSION_FIELDS = ('Version',) 

216_LISTFIELDS = ('Platform', 'Classifier', 'Obsoletes', 

217 'Requires', 'Provides', 'Obsoletes-Dist', 

218 'Provides-Dist', 'Requires-Dist', 'Requires-External', 

219 'Project-URL', 'Supported-Platform', 'Setup-Requires-Dist', 

220 'Provides-Extra', 'Extension', 'License-File') 

221_LISTTUPLEFIELDS = ('Project-URL',) 

222 

223_ELEMENTSFIELD = ('Keywords',) 

224 

225_UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description') 

226 

227_MISSING = object() 

228 

229_FILESAFE = re.compile('[^A-Za-z0-9.]+') 

230 

231 

232def _get_name_and_version(name, version, for_filename=False): 

233 """Return the distribution name with version. 

234 

235 If for_filename is true, return a filename-escaped form.""" 

236 if for_filename: 

237 # For both name and version any runs of non-alphanumeric or '.' 

238 # characters are replaced with a single '-'. Additionally any 

239 # spaces in the version string become '.' 

240 name = _FILESAFE.sub('-', name) 

241 version = _FILESAFE.sub('-', version.replace(' ', '.')) 

242 return '%s-%s' % (name, version) 

243 

244 

245class LegacyMetadata(object): 

246 """The legacy metadata of a release. 

247 

248 Supports versions 1.0, 1.1, 1.2, 2.0 and 1.3/2.1 (auto-detected). You can 

249 instantiate the class with one of these arguments (or none): 

250 - *path*, the path to a metadata file 

251 - *fileobj* give a file-like object with metadata as content 

252 - *mapping* is a dict-like object 

253 - *scheme* is a version scheme name 

254 """ 

255 # TODO document the mapping API and UNKNOWN default key 

256 

257 def __init__(self, path=None, fileobj=None, mapping=None, 

258 scheme='default'): 

259 if [path, fileobj, mapping].count(None) < 2: 

260 raise TypeError('path, fileobj and mapping are exclusive') 

261 self._fields = {} 

262 self.requires_files = [] 

263 self._dependencies = None 

264 self.scheme = scheme 

265 if path is not None: 

266 self.read(path) 

267 elif fileobj is not None: 

268 self.read_file(fileobj) 

269 elif mapping is not None: 

270 self.update(mapping) 

271 self.set_metadata_version() 

272 

273 def set_metadata_version(self): 

274 self._fields['Metadata-Version'] = _best_version(self._fields) 

275 

276 def _write_field(self, fileobj, name, value): 

277 fileobj.write('%s: %s\n' % (name, value)) 

278 

279 def __getitem__(self, name): 

280 return self.get(name) 

281 

282 def __setitem__(self, name, value): 

283 return self.set(name, value) 

284 

285 def __delitem__(self, name): 

286 field_name = self._convert_name(name) 

287 try: 

288 del self._fields[field_name] 

289 except KeyError: 

290 raise KeyError(name) 

291 

292 def __contains__(self, name): 

293 return (name in self._fields or 

294 self._convert_name(name) in self._fields) 

295 

296 def _convert_name(self, name): 

297 if name in _ALL_FIELDS: 

298 return name 

299 name = name.replace('-', '_').lower() 

300 return _ATTR2FIELD.get(name, name) 

301 

302 def _default_value(self, name): 

303 if name in _LISTFIELDS or name in _ELEMENTSFIELD: 

304 return [] 

305 return 'UNKNOWN' 

306 

307 def _remove_line_prefix(self, value): 

308 if self.metadata_version in ('1.0', '1.1'): 

309 return _LINE_PREFIX_PRE_1_2.sub('\n', value) 

310 else: 

311 return _LINE_PREFIX_1_2.sub('\n', value) 

312 

313 def __getattr__(self, name): 

314 if name in _ATTR2FIELD: 

315 return self[name] 

316 raise AttributeError(name) 

317 

318 # 

319 # Public API 

320 # 

321 

322# dependencies = property(_get_dependencies, _set_dependencies) 

323 

324 def get_fullname(self, filesafe=False): 

325 """Return the distribution name with version. 

326 

327 If filesafe is true, return a filename-escaped form.""" 

328 return _get_name_and_version(self['Name'], self['Version'], filesafe) 

329 

330 def is_field(self, name): 

331 """return True if name is a valid metadata key""" 

332 name = self._convert_name(name) 

333 return name in _ALL_FIELDS 

334 

335 def is_multi_field(self, name): 

336 name = self._convert_name(name) 

337 return name in _LISTFIELDS 

338 

339 def read(self, filepath): 

340 """Read the metadata values from a file path.""" 

341 fp = codecs.open(filepath, 'r', encoding='utf-8') 

342 try: 

343 self.read_file(fp) 

344 finally: 

345 fp.close() 

346 

347 def read_file(self, fileob): 

348 """Read the metadata values from a file object.""" 

349 msg = message_from_file(fileob) 

350 self._fields['Metadata-Version'] = msg['metadata-version'] 

351 

352 # When reading, get all the fields we can 

353 for field in _ALL_FIELDS: 

354 if field not in msg: 

355 continue 

356 if field in _LISTFIELDS: 

357 # we can have multiple lines 

358 values = msg.get_all(field) 

359 if field in _LISTTUPLEFIELDS and values is not None: 

360 values = [tuple(value.split(',')) for value in values] 

361 self.set(field, values) 

362 else: 

363 # single line 

364 value = msg[field] 

365 if value is not None and value != 'UNKNOWN': 

366 self.set(field, value) 

367 

368 # PEP 566 specifies that the body be used for the description, if 

369 # available 

370 body = msg.get_payload() 

371 self["Description"] = body if body else self["Description"] 

372 # logger.debug('Attempting to set metadata for %s', self) 

373 # self.set_metadata_version() 

374 

375 def write(self, filepath, skip_unknown=False): 

376 """Write the metadata fields to filepath.""" 

377 fp = codecs.open(filepath, 'w', encoding='utf-8') 

378 try: 

379 self.write_file(fp, skip_unknown) 

380 finally: 

381 fp.close() 

382 

383 def write_file(self, fileobject, skip_unknown=False): 

384 """Write the PKG-INFO format data to a file object.""" 

385 self.set_metadata_version() 

386 

387 for field in _version2fieldlist(self['Metadata-Version']): 

388 values = self.get(field) 

389 if skip_unknown and values in ('UNKNOWN', [], ['UNKNOWN']): 

390 continue 

391 if field in _ELEMENTSFIELD: 

392 self._write_field(fileobject, field, ','.join(values)) 

393 continue 

394 if field not in _LISTFIELDS: 

395 if field == 'Description': 

396 if self.metadata_version in ('1.0', '1.1'): 

397 values = values.replace('\n', '\n ') 

398 else: 

399 values = values.replace('\n', '\n |') 

400 values = [values] 

401 

402 if field in _LISTTUPLEFIELDS: 

403 values = [','.join(value) for value in values] 

404 

405 for value in values: 

406 self._write_field(fileobject, field, value) 

407 

408 def update(self, other=None, **kwargs): 

409 """Set metadata values from the given iterable `other` and kwargs. 

410 

411 Behavior is like `dict.update`: If `other` has a ``keys`` method, 

412 they are looped over and ``self[key]`` is assigned ``other[key]``. 

413 Else, ``other`` is an iterable of ``(key, value)`` iterables. 

414 

415 Keys that don't match a metadata field or that have an empty value are 

416 dropped. 

417 """ 

418 def _set(key, value): 

419 if key in _ATTR2FIELD and value: 

420 self.set(self._convert_name(key), value) 

421 

422 if not other: 

423 # other is None or empty container 

424 pass 

425 elif hasattr(other, 'keys'): 

426 for k in other.keys(): 

427 _set(k, other[k]) 

428 else: 

429 for k, v in other: 

430 _set(k, v) 

431 

432 if kwargs: 

433 for k, v in kwargs.items(): 

434 _set(k, v) 

435 

436 def set(self, name, value): 

437 """Control then set a metadata field.""" 

438 name = self._convert_name(name) 

439 

440 if ((name in _ELEMENTSFIELD or name == 'Platform') and 

441 not isinstance(value, (list, tuple))): 

442 if isinstance(value, string_types): 

443 value = [v.strip() for v in value.split(',')] 

444 else: 

445 value = [] 

446 elif (name in _LISTFIELDS and 

447 not isinstance(value, (list, tuple))): 

448 if isinstance(value, string_types): 

449 value = [value] 

450 else: 

451 value = [] 

452 

453 if logger.isEnabledFor(logging.WARNING): 

454 project_name = self['Name'] 

455 

456 scheme = get_scheme(self.scheme) 

457 if name in _PREDICATE_FIELDS and value is not None: 

458 for v in value: 

459 # check that the values are valid 

460 if not scheme.is_valid_matcher(v.split(';')[0]): 

461 logger.warning( 

462 "'%s': '%s' is not valid (field '%s')", 

463 project_name, v, name) 

464 # FIXME this rejects UNKNOWN, is that right? 

465 elif name in _VERSIONS_FIELDS and value is not None: 

466 if not scheme.is_valid_constraint_list(value): 

467 logger.warning("'%s': '%s' is not a valid version (field '%s')", 

468 project_name, value, name) 

469 elif name in _VERSION_FIELDS and value is not None: 

470 if not scheme.is_valid_version(value): 

471 logger.warning("'%s': '%s' is not a valid version (field '%s')", 

472 project_name, value, name) 

473 

474 if name in _UNICODEFIELDS: 

475 if name == 'Description': 

476 value = self._remove_line_prefix(value) 

477 

478 self._fields[name] = value 

479 

480 def get(self, name, default=_MISSING): 

481 """Get a metadata field.""" 

482 name = self._convert_name(name) 

483 if name not in self._fields: 

484 if default is _MISSING: 

485 default = self._default_value(name) 

486 return default 

487 if name in _UNICODEFIELDS: 

488 value = self._fields[name] 

489 return value 

490 elif name in _LISTFIELDS: 

491 value = self._fields[name] 

492 if value is None: 

493 return [] 

494 res = [] 

495 for val in value: 

496 if name not in _LISTTUPLEFIELDS: 

497 res.append(val) 

498 else: 

499 # That's for Project-URL 

500 res.append((val[0], val[1])) 

501 return res 

502 

503 elif name in _ELEMENTSFIELD: 

504 value = self._fields[name] 

505 if isinstance(value, string_types): 

506 return value.split(',') 

507 return self._fields[name] 

508 

509 def check(self, strict=False): 

510 """Check if the metadata is compliant. If strict is True then raise if 

511 no Name or Version are provided""" 

512 self.set_metadata_version() 

513 

514 # XXX should check the versions (if the file was loaded) 

515 missing, warnings = [], [] 

516 

517 for attr in ('Name', 'Version'): # required by PEP 345 

518 if attr not in self: 

519 missing.append(attr) 

520 

521 if strict and missing != []: 

522 msg = 'missing required metadata: %s' % ', '.join(missing) 

523 raise MetadataMissingError(msg) 

524 

525 for attr in ('Home-page', 'Author'): 

526 if attr not in self: 

527 missing.append(attr) 

528 

529 # checking metadata 1.2 (XXX needs to check 1.1, 1.0) 

530 if self['Metadata-Version'] != '1.2': 

531 return missing, warnings 

532 

533 scheme = get_scheme(self.scheme) 

534 

535 def are_valid_constraints(value): 

536 for v in value: 

537 if not scheme.is_valid_matcher(v.split(';')[0]): 

538 return False 

539 return True 

540 

541 for fields, controller in ((_PREDICATE_FIELDS, are_valid_constraints), 

542 (_VERSIONS_FIELDS, 

543 scheme.is_valid_constraint_list), 

544 (_VERSION_FIELDS, 

545 scheme.is_valid_version)): 

546 for field in fields: 

547 value = self.get(field, None) 

548 if value is not None and not controller(value): 

549 warnings.append("Wrong value for '%s': %s" % (field, value)) 

550 

551 return missing, warnings 

552 

553 def todict(self, skip_missing=False): 

554 """Return fields as a dict. 

555 

556 Field names will be converted to use the underscore-lowercase style 

557 instead of hyphen-mixed case (i.e. home_page instead of Home-page). 

558 This is as per https://www.python.org/dev/peps/pep-0566/#id17. 

559 """ 

560 self.set_metadata_version() 

561 

562 fields = _version2fieldlist(self['Metadata-Version']) 

563 

564 data = {} 

565 

566 for field_name in fields: 

567 if not skip_missing or field_name in self._fields: 

568 key = _FIELD2ATTR[field_name] 

569 if key != 'project_url': 

570 data[key] = self[field_name] 

571 else: 

572 data[key] = [','.join(u) for u in self[field_name]] 

573 

574 return data 

575 

576 def add_requirements(self, requirements): 

577 if self['Metadata-Version'] == '1.1': 

578 # we can't have 1.1 metadata *and* Setuptools requires 

579 for field in ('Obsoletes', 'Requires', 'Provides'): 

580 if field in self: 

581 del self[field] 

582 self['Requires-Dist'] += requirements 

583 

584 # Mapping API 

585 # TODO could add iter* variants 

586 

587 def keys(self): 

588 return list(_version2fieldlist(self['Metadata-Version'])) 

589 

590 def __iter__(self): 

591 for key in self.keys(): 

592 yield key 

593 

594 def values(self): 

595 return [self[key] for key in self.keys()] 

596 

597 def items(self): 

598 return [(key, self[key]) for key in self.keys()] 

599 

600 def __repr__(self): 

601 return '<%s %s %s>' % (self.__class__.__name__, self.name, 

602 self.version) 

603 

604 

605METADATA_FILENAME = 'pydist.json' 

606WHEEL_METADATA_FILENAME = 'metadata.json' 

607LEGACY_METADATA_FILENAME = 'METADATA' 

608 

609 

610class Metadata(object): 

611 """ 

612 The metadata of a release. This implementation uses 2.1 

613 metadata where possible. If not possible, it wraps a LegacyMetadata 

614 instance which handles the key-value metadata format. 

615 """ 

616 

617 METADATA_VERSION_MATCHER = re.compile(r'^\d+(\.\d+)*$') 

618 

619 NAME_MATCHER = re.compile('^[0-9A-Z]([0-9A-Z_.-]*[0-9A-Z])?$', re.I) 

620 

621 FIELDNAME_MATCHER = re.compile('^[A-Z]([0-9A-Z-]*[0-9A-Z])?$', re.I) 

622 

623 VERSION_MATCHER = PEP440_VERSION_RE 

624 

625 SUMMARY_MATCHER = re.compile('.{1,2047}') 

626 

627 METADATA_VERSION = '2.0' 

628 

629 GENERATOR = 'distlib (%s)' % __version__ 

630 

631 MANDATORY_KEYS = { 

632 'name': (), 

633 'version': (), 

634 'summary': ('legacy',), 

635 } 

636 

637 INDEX_KEYS = ('name version license summary description author ' 

638 'author_email keywords platform home_page classifiers ' 

639 'download_url') 

640 

641 DEPENDENCY_KEYS = ('extras run_requires test_requires build_requires ' 

642 'dev_requires provides meta_requires obsoleted_by ' 

643 'supports_environments') 

644 

645 SYNTAX_VALIDATORS = { 

646 'metadata_version': (METADATA_VERSION_MATCHER, ()), 

647 'name': (NAME_MATCHER, ('legacy',)), 

648 'version': (VERSION_MATCHER, ('legacy',)), 

649 'summary': (SUMMARY_MATCHER, ('legacy',)), 

650 'dynamic': (FIELDNAME_MATCHER, ('legacy',)), 

651 } 

652 

653 __slots__ = ('_legacy', '_data', 'scheme') 

654 

655 def __init__(self, path=None, fileobj=None, mapping=None, 

656 scheme='default'): 

657 if [path, fileobj, mapping].count(None) < 2: 

658 raise TypeError('path, fileobj and mapping are exclusive') 

659 self._legacy = None 

660 self._data = None 

661 self.scheme = scheme 

662 #import pdb; pdb.set_trace() 

663 if mapping is not None: 

664 try: 

665 self._validate_mapping(mapping, scheme) 

666 self._data = mapping 

667 except MetadataUnrecognizedVersionError: 

668 self._legacy = LegacyMetadata(mapping=mapping, scheme=scheme) 

669 self.validate() 

670 else: 

671 data = None 

672 if path: 

673 with open(path, 'rb') as f: 

674 data = f.read() 

675 elif fileobj: 

676 data = fileobj.read() 

677 if data is None: 

678 # Initialised with no args - to be added 

679 self._data = { 

680 'metadata_version': self.METADATA_VERSION, 

681 'generator': self.GENERATOR, 

682 } 

683 else: 

684 if not isinstance(data, text_type): 

685 data = data.decode('utf-8') 

686 try: 

687 self._data = json.loads(data) 

688 self._validate_mapping(self._data, scheme) 

689 except ValueError: 

690 # Note: MetadataUnrecognizedVersionError does not 

691 # inherit from ValueError (it's a DistlibException, 

692 # which should not inherit from ValueError). 

693 # The ValueError comes from the json.load - if that 

694 # succeeds and we get a validation error, we want 

695 # that to propagate 

696 self._legacy = LegacyMetadata(fileobj=StringIO(data), 

697 scheme=scheme) 

698 self.validate() 

699 

700 common_keys = set(('name', 'version', 'license', 'keywords', 'summary')) 

701 

702 none_list = (None, list) 

703 none_dict = (None, dict) 

704 

705 mapped_keys = { 

706 'run_requires': ('Requires-Dist', list), 

707 'build_requires': ('Setup-Requires-Dist', list), 

708 'dev_requires': none_list, 

709 'test_requires': none_list, 

710 'meta_requires': none_list, 

711 'extras': ('Provides-Extra', list), 

712 'modules': none_list, 

713 'namespaces': none_list, 

714 'exports': none_dict, 

715 'commands': none_dict, 

716 'classifiers': ('Classifier', list), 

717 'source_url': ('Download-URL', None), 

718 'metadata_version': ('Metadata-Version', None), 

719 } 

720 

721 del none_list, none_dict 

722 

723 def __getattribute__(self, key): 

724 common = object.__getattribute__(self, 'common_keys') 

725 mapped = object.__getattribute__(self, 'mapped_keys') 

726 if key in mapped: 

727 lk, maker = mapped[key] 

728 if self._legacy: 

729 if lk is None: 

730 result = None if maker is None else maker() 

731 else: 

732 result = self._legacy.get(lk) 

733 else: 

734 value = None if maker is None else maker() 

735 if key not in ('commands', 'exports', 'modules', 'namespaces', 

736 'classifiers'): 

737 result = self._data.get(key, value) 

738 else: 

739 # special cases for PEP 459 

740 sentinel = object() 

741 result = sentinel 

742 d = self._data.get('extensions') 

743 if d: 

744 if key == 'commands': 

745 result = d.get('python.commands', value) 

746 elif key == 'classifiers': 

747 d = d.get('python.details') 

748 if d: 

749 result = d.get(key, value) 

750 else: 

751 d = d.get('python.exports') 

752 if not d: 

753 d = self._data.get('python.exports') 

754 if d: 

755 result = d.get(key, value) 

756 if result is sentinel: 

757 result = value 

758 elif key not in common: 

759 result = object.__getattribute__(self, key) 

760 elif self._legacy: 

761 result = self._legacy.get(key) 

762 else: 

763 result = self._data.get(key) 

764 return result 

765 

766 def _validate_value(self, key, value, scheme=None): 

767 if key in self.SYNTAX_VALIDATORS: 

768 pattern, exclusions = self.SYNTAX_VALIDATORS[key] 

769 if (scheme or self.scheme) not in exclusions: 

770 m = pattern.match(value) 

771 if not m: 

772 raise MetadataInvalidError("'%s' is an invalid value for " 

773 "the '%s' property" % (value, 

774 key)) 

775 

776 def __setattr__(self, key, value): 

777 self._validate_value(key, value) 

778 common = object.__getattribute__(self, 'common_keys') 

779 mapped = object.__getattribute__(self, 'mapped_keys') 

780 if key in mapped: 

781 lk, _ = mapped[key] 

782 if self._legacy: 

783 if lk is None: 

784 raise NotImplementedError 

785 self._legacy[lk] = value 

786 elif key not in ('commands', 'exports', 'modules', 'namespaces', 

787 'classifiers'): 

788 self._data[key] = value 

789 else: 

790 # special cases for PEP 459 

791 d = self._data.setdefault('extensions', {}) 

792 if key == 'commands': 

793 d['python.commands'] = value 

794 elif key == 'classifiers': 

795 d = d.setdefault('python.details', {}) 

796 d[key] = value 

797 else: 

798 d = d.setdefault('python.exports', {}) 

799 d[key] = value 

800 elif key not in common: 

801 object.__setattr__(self, key, value) 

802 else: 

803 if key == 'keywords': 

804 if isinstance(value, string_types): 

805 value = value.strip() 

806 if value: 

807 value = value.split() 

808 else: 

809 value = [] 

810 if self._legacy: 

811 self._legacy[key] = value 

812 else: 

813 self._data[key] = value 

814 

815 @property 

816 def name_and_version(self): 

817 return _get_name_and_version(self.name, self.version, True) 

818 

819 @property 

820 def provides(self): 

821 if self._legacy: 

822 result = self._legacy['Provides-Dist'] 

823 else: 

824 result = self._data.setdefault('provides', []) 

825 s = '%s (%s)' % (self.name, self.version) 

826 if s not in result: 

827 result.append(s) 

828 return result 

829 

830 @provides.setter 

831 def provides(self, value): 

832 if self._legacy: 

833 self._legacy['Provides-Dist'] = value 

834 else: 

835 self._data['provides'] = value 

836 

837 def get_requirements(self, reqts, extras=None, env=None): 

838 """ 

839 Base method to get dependencies, given a set of extras 

840 to satisfy and an optional environment context. 

841 :param reqts: A list of sometimes-wanted dependencies, 

842 perhaps dependent on extras and environment. 

843 :param extras: A list of optional components being requested. 

844 :param env: An optional environment for marker evaluation. 

845 """ 

846 if self._legacy: 

847 result = reqts 

848 else: 

849 result = [] 

850 extras = get_extras(extras or [], self.extras) 

851 for d in reqts: 

852 if 'extra' not in d and 'environment' not in d: 

853 # unconditional 

854 include = True 

855 else: 

856 if 'extra' not in d: 

857 # Not extra-dependent - only environment-dependent 

858 include = True 

859 else: 

860 include = d.get('extra') in extras 

861 if include: 

862 # Not excluded because of extras, check environment 

863 marker = d.get('environment') 

864 if marker: 

865 include = interpret(marker, env) 

866 if include: 

867 result.extend(d['requires']) 

868 for key in ('build', 'dev', 'test'): 

869 e = ':%s:' % key 

870 if e in extras: 

871 extras.remove(e) 

872 # A recursive call, but it should terminate since 'test' 

873 # has been removed from the extras 

874 reqts = self._data.get('%s_requires' % key, []) 

875 result.extend(self.get_requirements(reqts, extras=extras, 

876 env=env)) 

877 return result 

878 

879 @property 

880 def dictionary(self): 

881 if self._legacy: 

882 return self._from_legacy() 

883 return self._data 

884 

885 @property 

886 def dependencies(self): 

887 if self._legacy: 

888 raise NotImplementedError 

889 else: 

890 return extract_by_key(self._data, self.DEPENDENCY_KEYS) 

891 

892 @dependencies.setter 

893 def dependencies(self, value): 

894 if self._legacy: 

895 raise NotImplementedError 

896 else: 

897 self._data.update(value) 

898 

899 def _validate_mapping(self, mapping, scheme): 

900 if mapping.get('metadata_version') != self.METADATA_VERSION: 

901 raise MetadataUnrecognizedVersionError() 

902 missing = [] 

903 for key, exclusions in self.MANDATORY_KEYS.items(): 

904 if key not in mapping: 

905 if scheme not in exclusions: 

906 missing.append(key) 

907 if missing: 

908 msg = 'Missing metadata items: %s' % ', '.join(missing) 

909 raise MetadataMissingError(msg) 

910 for k, v in mapping.items(): 

911 self._validate_value(k, v, scheme) 

912 

913 def validate(self): 

914 if self._legacy: 

915 missing, warnings = self._legacy.check(True) 

916 if missing or warnings: 

917 logger.warning('Metadata: missing: %s, warnings: %s', 

918 missing, warnings) 

919 else: 

920 self._validate_mapping(self._data, self.scheme) 

921 

922 def todict(self): 

923 if self._legacy: 

924 return self._legacy.todict(True) 

925 else: 

926 result = extract_by_key(self._data, self.INDEX_KEYS) 

927 return result 

928 

929 def _from_legacy(self): 

930 assert self._legacy and not self._data 

931 result = { 

932 'metadata_version': self.METADATA_VERSION, 

933 'generator': self.GENERATOR, 

934 } 

935 lmd = self._legacy.todict(True) # skip missing ones 

936 for k in ('name', 'version', 'license', 'summary', 'description', 

937 'classifier'): 

938 if k in lmd: 

939 if k == 'classifier': 

940 nk = 'classifiers' 

941 else: 

942 nk = k 

943 result[nk] = lmd[k] 

944 kw = lmd.get('Keywords', []) 

945 if kw == ['']: 

946 kw = [] 

947 result['keywords'] = kw 

948 keys = (('requires_dist', 'run_requires'), 

949 ('setup_requires_dist', 'build_requires')) 

950 for ok, nk in keys: 

951 if ok in lmd and lmd[ok]: 

952 result[nk] = [{'requires': lmd[ok]}] 

953 result['provides'] = self.provides 

954 author = {} 

955 maintainer = {} 

956 return result 

957 

958 LEGACY_MAPPING = { 

959 'name': 'Name', 

960 'version': 'Version', 

961 ('extensions', 'python.details', 'license'): 'License', 

962 'summary': 'Summary', 

963 'description': 'Description', 

964 ('extensions', 'python.project', 'project_urls', 'Home'): 'Home-page', 

965 ('extensions', 'python.project', 'contacts', 0, 'name'): 'Author', 

966 ('extensions', 'python.project', 'contacts', 0, 'email'): 'Author-email', 

967 'source_url': 'Download-URL', 

968 ('extensions', 'python.details', 'classifiers'): 'Classifier', 

969 } 

970 

971 def _to_legacy(self): 

972 def process_entries(entries): 

973 reqts = set() 

974 for e in entries: 

975 extra = e.get('extra') 

976 env = e.get('environment') 

977 rlist = e['requires'] 

978 for r in rlist: 

979 if not env and not extra: 

980 reqts.add(r) 

981 else: 

982 marker = '' 

983 if extra: 

984 marker = 'extra == "%s"' % extra 

985 if env: 

986 if marker: 

987 marker = '(%s) and %s' % (env, marker) 

988 else: 

989 marker = env 

990 reqts.add(';'.join((r, marker))) 

991 return reqts 

992 

993 assert self._data and not self._legacy 

994 result = LegacyMetadata() 

995 nmd = self._data 

996 # import pdb; pdb.set_trace() 

997 for nk, ok in self.LEGACY_MAPPING.items(): 

998 if not isinstance(nk, tuple): 

999 if nk in nmd: 

1000 result[ok] = nmd[nk] 

1001 else: 

1002 d = nmd 

1003 found = True 

1004 for k in nk: 

1005 try: 

1006 d = d[k] 

1007 except (KeyError, IndexError): 

1008 found = False 

1009 break 

1010 if found: 

1011 result[ok] = d 

1012 r1 = process_entries(self.run_requires + self.meta_requires) 

1013 r2 = process_entries(self.build_requires + self.dev_requires) 

1014 if self.extras: 

1015 result['Provides-Extra'] = sorted(self.extras) 

1016 result['Requires-Dist'] = sorted(r1) 

1017 result['Setup-Requires-Dist'] = sorted(r2) 

1018 # TODO: any other fields wanted 

1019 return result 

1020 

1021 def write(self, path=None, fileobj=None, legacy=False, skip_unknown=True): 

1022 if [path, fileobj].count(None) != 1: 

1023 raise ValueError('Exactly one of path and fileobj is needed') 

1024 self.validate() 

1025 if legacy: 

1026 if self._legacy: 

1027 legacy_md = self._legacy 

1028 else: 

1029 legacy_md = self._to_legacy() 

1030 if path: 

1031 legacy_md.write(path, skip_unknown=skip_unknown) 

1032 else: 

1033 legacy_md.write_file(fileobj, skip_unknown=skip_unknown) 

1034 else: 

1035 if self._legacy: 

1036 d = self._from_legacy() 

1037 else: 

1038 d = self._data 

1039 if fileobj: 

1040 json.dump(d, fileobj, ensure_ascii=True, indent=2, 

1041 sort_keys=True) 

1042 else: 

1043 with codecs.open(path, 'w', 'utf-8') as f: 

1044 json.dump(d, f, ensure_ascii=True, indent=2, 

1045 sort_keys=True) 

1046 

1047 def add_requirements(self, requirements): 

1048 if self._legacy: 

1049 self._legacy.add_requirements(requirements) 

1050 else: 

1051 run_requires = self._data.setdefault('run_requires', []) 

1052 always = None 

1053 for entry in run_requires: 

1054 if 'environment' not in entry and 'extra' not in entry: 

1055 always = entry 

1056 break 

1057 if always is None: 

1058 always = { 'requires': requirements } 

1059 run_requires.insert(0, always) 

1060 else: 

1061 rset = set(always['requires']) | set(requirements) 

1062 always['requires'] = sorted(rset) 

1063 

1064 def __repr__(self): 

1065 name = self.name or '(no name)' 

1066 version = self.version or 'no version' 

1067 return '<%s %s %s (%s)>' % (self.__class__.__name__, 

1068 self.metadata_version, name, version)