src/eric7/PipInterface/piplicenses.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 9098
fb9351497cea
child 9218
71cf3979a6c9
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 # vim:fenc=utf-8 ff=unix ft=python ts=4 sw=4 sts=4 si et
4 """
5 pip-licenses
6
7 MIT License
8
9 Copyright (c) 2018 raimon
10
11 Permission is hereby granted, free of charge, to any person obtaining a copy
12 of this software and associated documentation files (the "Software"), to deal
13 in the Software without restriction, including without limitation the rights
14 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 copies of the Software, and to permit persons to whom the Software is
16 furnished to do so, subject to the following conditions:
17
18 The above copyright notice and this permission notice shall be included in all
19 copies or substantial portions of the Software.
20
21 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27 SOFTWARE.
28 """
29
30 #
31 # Modified to be used within the eric-ide project.
32 #
33 # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
34 #
35
36 import argparse
37 import codecs
38 import glob
39 import json
40 import os
41 import sys
42 from collections import Counter
43 from email import message_from_string
44 from email.parser import FeedParser
45 from enum import Enum, auto
46 from typing import List, Optional, Sequence, Text
47
48
49 def get_installed_distributions(local_only=True, user_only=False):
50 try:
51 from pip._internal.metadata import get_environment
52 except ImportError:
53 # For backward compatibility with pip version 20.3.4
54 from pip._internal.utils import misc
55 return misc.get_installed_distributions(
56 local_only=local_only,
57 user_only=user_only
58 )
59 else:
60 from pip._internal.utils.compat import stdlib_pkgs
61 dists = get_environment(None).iter_installed_distributions(
62 local_only=local_only,
63 user_only=user_only,
64 skip=stdlib_pkgs,
65 include_editables=True,
66 editables_only=False,
67 )
68 return [d._dist for d in dists]
69
70
71 __pkgname__ = 'pip-licenses'
72 __version__ = '3.5.4'
73 __author__ = 'raimon'
74 __license__ = 'MIT'
75 __summary__ = ('Dump the software license list of '
76 'Python packages installed with pip.')
77 __url__ = 'https://github.com/raimon49/pip-licenses'
78
79
80 FIELD_NAMES = (
81 'Name',
82 'Version',
83 'License',
84 'LicenseFile',
85 'LicenseText',
86 'NoticeFile',
87 'NoticeText',
88 'Author',
89 'Description',
90 'URL',
91 )
92
93
94 DEFAULT_OUTPUT_FIELDS = (
95 'Name',
96 'Version',
97 )
98
99
100 SUMMARY_OUTPUT_FIELDS = (
101 'Count',
102 'License',
103 )
104
105
106 METADATA_KEYS = (
107 'home-page',
108 'author',
109 'license',
110 'summary',
111 'license_classifier',
112 )
113
114 # Mapping of FIELD_NAMES to METADATA_KEYS where they differ by more than case
115 FIELDS_TO_METADATA_KEYS = {
116 'URL': 'home-page',
117 'Description': 'summary',
118 'License-Metadata': 'license',
119 'License-Classifier': 'license_classifier',
120 }
121
122
123 SYSTEM_PACKAGES = (
124 __pkgname__,
125 'pip',
126 'setuptools',
127 'wheel',
128 )
129
130 LICENSE_UNKNOWN = 'UNKNOWN'
131
132
133 def get_packages(args: "CustomNamespace"):
134
135 def get_pkg_included_file(pkg, file_names):
136 """
137 Attempt to find the package's included file on disk and return the
138 tuple (included_file_path, included_file_contents).
139 """
140 included_file = LICENSE_UNKNOWN
141 included_text = LICENSE_UNKNOWN
142 pkg_dirname = "{}-{}.dist-info".format(
143 pkg.project_name.replace("-", "_"), pkg.version)
144 patterns = []
145 [patterns.extend(sorted(glob.glob(os.path.join(pkg.location,
146 pkg_dirname,
147 f))))
148 for f in file_names]
149 for test_file in patterns:
150 if os.path.exists(test_file) and not os.path.isdir(test_file):
151 included_file = test_file
152 with open(test_file, encoding='utf-8',
153 errors='backslashreplace') as included_file_handle:
154 included_text = included_file_handle.read()
155 break
156 return (included_file, included_text)
157
158 def get_pkg_info(pkg):
159 (license_file, license_text) = get_pkg_included_file(
160 pkg,
161 ('LICENSE*', 'LICENCE*', 'COPYING*')
162 )
163 (notice_file, notice_text) = get_pkg_included_file(
164 pkg,
165 ('NOTICE*',)
166 )
167 pkg_info = {
168 'name': pkg.project_name,
169 'version': pkg.version,
170 'namever': str(pkg),
171 'licensefile': license_file,
172 'licensetext': license_text,
173 'noticefile': notice_file,
174 'noticetext': notice_text,
175 }
176 metadata = None
177 if pkg.has_metadata('METADATA'):
178 metadata = pkg.get_metadata('METADATA')
179
180 if pkg.has_metadata('PKG-INFO') and metadata is None:
181 metadata = pkg.get_metadata('PKG-INFO')
182
183 if metadata is None:
184 for key in METADATA_KEYS:
185 pkg_info[key] = LICENSE_UNKNOWN
186
187 return pkg_info
188
189 feed_parser = FeedParser()
190 feed_parser.feed(metadata)
191 parsed_metadata = feed_parser.close()
192
193 for key in METADATA_KEYS:
194 pkg_info[key] = parsed_metadata.get(key, LICENSE_UNKNOWN)
195
196 if metadata is not None:
197 message = message_from_string(metadata)
198 pkg_info['license_classifier'] = \
199 find_license_from_classifier(message)
200
201 if args.filter_strings:
202 for k in pkg_info:
203 if isinstance(pkg_info[k], list):
204 for i, item in enumerate(pkg_info[k]):
205 pkg_info[k][i] = item. \
206 encode(args.filter_code_page, errors="ignore"). \
207 decode(args.filter_code_page)
208 else:
209 pkg_info[k] = pkg_info[k]. \
210 encode(args.filter_code_page, errors="ignore"). \
211 decode(args.filter_code_page)
212
213 return pkg_info
214
215 pkgs = get_installed_distributions(
216 local_only=args.local_only,
217 user_only=args.user_only,
218 )
219 ignore_pkgs_as_lower = [pkg.lower() for pkg in args.ignore_packages]
220 pkgs_as_lower = [pkg.lower() for pkg in args.packages]
221
222 fail_on_licenses = set()
223 if args.fail_on:
224 fail_on_licenses = set(map(str.strip, args.fail_on.split(";")))
225
226 allow_only_licenses = set()
227 if args.allow_only:
228 allow_only_licenses = set(map(str.strip, args.allow_only.split(";")))
229
230 for pkg in pkgs:
231 pkg_name = pkg.project_name
232
233 if pkg_name.lower() in ignore_pkgs_as_lower:
234 continue
235
236 if pkgs_as_lower and pkg_name.lower() not in pkgs_as_lower:
237 continue
238
239 if not args.with_system and pkg_name in SYSTEM_PACKAGES:
240 continue
241
242 pkg_info = get_pkg_info(pkg)
243
244 license_names = select_license_by_source(
245 args.from_,
246 pkg_info['license_classifier'],
247 pkg_info['license'])
248
249 if fail_on_licenses:
250 failed_licenses = license_names.intersection(fail_on_licenses)
251 if failed_licenses:
252 sys.stderr.write(
253 "fail-on license {} was found for package "
254 "{}:{}".format(
255 '; '.join(sorted(failed_licenses)),
256 pkg_info['name'],
257 pkg_info['version'])
258 )
259 sys.exit(1)
260
261 if allow_only_licenses:
262 uncommon_licenses = license_names.difference(allow_only_licenses)
263 if len(uncommon_licenses) == len(license_names):
264 sys.stderr.write(
265 "license {} not in allow-only licenses was found"
266 " for package {}:{}".format(
267 '; '.join(sorted(uncommon_licenses)),
268 pkg_info['name'],
269 pkg_info['version'])
270 )
271 sys.exit(1)
272
273 yield pkg_info
274
275
276 def create_licenses_list(
277 args: "CustomNamespace", output_fields=DEFAULT_OUTPUT_FIELDS):
278
279 licenses = []
280 for pkg in get_packages(args):
281 row = {}
282 for field in output_fields:
283 if field == 'License':
284 license_set = select_license_by_source(
285 args.from_, pkg['license_classifier'], pkg['license'])
286 license_str = '; '.join(sorted(license_set))
287 row[field] = license_str
288 elif field == 'License-Classifier':
289 row[field] = ('; '.join(sorted(pkg['license_classifier']))
290 or LICENSE_UNKNOWN)
291 elif field.lower() in pkg:
292 row[field] = pkg[field.lower()]
293 else:
294 row[field] = pkg[FIELDS_TO_METADATA_KEYS[field]]
295 licenses.append(row)
296
297 return licenses
298
299
300 def create_summary_list(args: "CustomNamespace"):
301 counts = Counter(
302 '; '.join(sorted(select_license_by_source(
303 args.from_, pkg['license_classifier'], pkg['license'])))
304 for pkg in get_packages(args))
305
306 licenses = []
307 for license, count in counts.items():
308 licenses.append({
309 "Count": count,
310 "License": license,
311 })
312
313 return licenses
314
315
316 def find_license_from_classifier(message):
317 licenses = []
318 for k, v in message.items():
319 if k == 'Classifier' and v.startswith('License'):
320 license = v.split(' :: ')[-1]
321
322 # Through the declaration of 'Classifier: License :: OSI Approved'
323 if license != 'OSI Approved':
324 licenses.append(license)
325
326 return licenses
327
328
329 def select_license_by_source(from_source, license_classifier, license_meta):
330 license_classifier_set = set(license_classifier) or {LICENSE_UNKNOWN}
331 if (from_source == FromArg.CLASSIFIER or
332 from_source == FromArg.MIXED and len(license_classifier) > 0):
333 return license_classifier_set
334 else:
335 return {license_meta}
336
337
338 def get_output_fields(args: "CustomNamespace"):
339 if args.summary:
340 return list(SUMMARY_OUTPUT_FIELDS)
341
342 output_fields = list(DEFAULT_OUTPUT_FIELDS)
343
344 if args.from_ == FromArg.ALL:
345 output_fields.append('License-Metadata')
346 output_fields.append('License-Classifier')
347 else:
348 output_fields.append('License')
349
350 if args.with_authors:
351 output_fields.append('Author')
352
353 if args.with_urls:
354 output_fields.append('URL')
355
356 if args.with_description:
357 output_fields.append('Description')
358
359 if args.with_license_file:
360 if not args.no_license_path:
361 output_fields.append('LicenseFile')
362
363 output_fields.append('LicenseText')
364
365 if args.with_notice_file:
366 output_fields.append('NoticeText')
367 if not args.no_license_path:
368 output_fields.append('NoticeFile')
369
370 return output_fields
371
372
373 def create_output_string(args: "CustomNamespace"):
374 output_fields = get_output_fields(args)
375
376 if args.summary:
377 licenses = create_summary_list(args)
378 else:
379 licenses = create_licenses_list(args, output_fields)
380
381 return json.dumps(licenses)
382
383
384 class CustomHelpFormatter(argparse.HelpFormatter): # pragma: no cover
385 def __init__(
386 self, prog: Text, indent_increment: int = 2,
387 max_help_position: int = 24, width: Optional[int] = None
388 ) -> None:
389 max_help_position = 30
390 super().__init__(
391 prog, indent_increment=indent_increment,
392 max_help_position=max_help_position, width=width)
393
394 def _format_action(self, action: argparse.Action) -> str:
395 flag_indent_argument: bool = False
396 text = self._expand_help(action)
397 separator_pos = text[:3].find('|')
398 if separator_pos != -1 and 'I' in text[:separator_pos]:
399 self._indent()
400 flag_indent_argument = True
401 help_str = super()._format_action(action)
402 if flag_indent_argument:
403 self._dedent()
404 return help_str
405
406 def _expand_help(self, action: argparse.Action) -> str:
407 if isinstance(action.default, Enum):
408 default_value = enum_key_to_value(action.default)
409 return self._get_help_string(action) % {'default': default_value}
410 return super()._expand_help(action)
411
412 def _split_lines(self, text: Text, width: int) -> List[str]:
413 separator_pos = text[:3].find('|')
414 if separator_pos != -1:
415 flag_splitlines: bool = 'R' in text[:separator_pos]
416 text = text[separator_pos + 1:]
417 if flag_splitlines:
418 return text.splitlines()
419 return super()._split_lines(text, width)
420
421
422 class CustomNamespace(argparse.Namespace):
423 from_: "FromArg"
424 order: "OrderArg"
425 summary: bool
426 local_only: bool
427 user_only:bool
428 output_file: str
429 ignore_packages: List[str]
430 packages: List[str]
431 with_system: bool
432 with_authors: bool
433 with_urls: bool
434 with_description: bool
435 with_license_file: bool
436 no_license_path: bool
437 with_notice_file: bool
438 filter_strings: bool
439 filter_code_page: str
440 fail_on: Optional[str]
441 allow_only: Optional[str]
442
443
444 class CompatibleArgumentParser(argparse.ArgumentParser):
445 def parse_args(self, args: Optional[Sequence[Text]] = None,
446 namespace: CustomNamespace = None) -> CustomNamespace:
447 args = super().parse_args(args, namespace)
448 self._verify_args(args)
449 return args
450
451 def _verify_args(self, args: CustomNamespace):
452 if args.with_license_file is False and (
453 args.no_license_path is True or
454 args.with_notice_file is True):
455 self.error(
456 "'--no-license-path' and '--with-notice-file' require "
457 "the '--with-license-file' option to be set")
458 if args.filter_strings is False and \
459 args.filter_code_page != 'latin1':
460 self.error(
461 "'--filter-code-page' requires the '--filter-strings' "
462 "option to be set")
463 try:
464 codecs.lookup(args.filter_code_page)
465 except LookupError:
466 self.error(
467 "invalid code page '%s' given for '--filter-code-page, "
468 "check https://docs.python.org/3/library/codecs.html"
469 "#standard-encodings for valid code pages"
470 % args.filter_code_page)
471
472
473 class NoValueEnum(Enum):
474 def __repr__(self): # pragma: no cover
475 return '<%s.%s>' % (self.__class__.__name__, self.name)
476
477
478 class FromArg(NoValueEnum):
479 META = M = auto()
480 CLASSIFIER = C = auto()
481 MIXED = MIX = auto()
482 ALL = auto()
483
484
485 class OrderArg(NoValueEnum):
486 COUNT = C = auto()
487 LICENSE = L = auto()
488 NAME = N = auto()
489 AUTHOR = A = auto()
490 URL = U = auto()
491
492
493 def value_to_enum_key(value: str) -> str:
494 return value.replace('-', '_').upper()
495
496
497 def enum_key_to_value(enum_key: Enum) -> str:
498 return enum_key.name.replace('_', '-').lower()
499
500
501 def choices_from_enum(enum_cls: NoValueEnum) -> List[str]:
502 return [key.replace('_', '-').lower()
503 for key in enum_cls.__members__.keys()]
504
505
506 MAP_DEST_TO_ENUM = {
507 'from_': FromArg,
508 'order': OrderArg,
509 }
510
511
512 class SelectAction(argparse.Action):
513 def __call__(
514 self, parser: argparse.ArgumentParser,
515 namespace: argparse.Namespace,
516 values: Text,
517 option_string: Optional[Text] = None,
518 ) -> None:
519 enum_cls = MAP_DEST_TO_ENUM[self.dest]
520 values = value_to_enum_key(values)
521 setattr(namespace, self.dest, getattr(enum_cls, values))
522
523
524 def create_parser():
525 parser = CompatibleArgumentParser(
526 description=__summary__,
527 formatter_class=CustomHelpFormatter)
528
529 common_options = parser.add_argument_group('Common options')
530 format_options = parser.add_argument_group('Format options')
531 verify_options = parser.add_argument_group('Verify options')
532
533 parser.add_argument(
534 '-v', '--version',
535 action='version',
536 version='%(prog)s ' + __version__)
537
538 common_options.add_argument(
539 '--from',
540 dest='from_',
541 action=SelectAction, type=str,
542 default=FromArg.MIXED, metavar='SOURCE',
543 choices=choices_from_enum(FromArg),
544 help='R|where to find license information\n'
545 '"meta", "classifier, "mixed", "all"\n'
546 '(default: %(default)s)')
547 common_options.add_argument(
548 '-o', '--order',
549 action=SelectAction, type=str,
550 default=OrderArg.NAME, metavar='COL',
551 choices=choices_from_enum(OrderArg),
552 help='R|order by column\n'
553 '"name", "license", "author", "url"\n'
554 '(default: %(default)s)')
555 common_options.add_argument(
556 '--summary',
557 action='store_true',
558 default=False,
559 help='dump summary of each license')
560 common_options.add_argument(
561 '--output-file',
562 action='store', type=str,
563 help='save license list to file')
564 common_options.add_argument(
565 '-i', '--ignore-packages',
566 action='store', type=str,
567 nargs='+', metavar='PKG',
568 default=[],
569 help='ignore package name in dumped list')
570 common_options.add_argument(
571 '-p', '--packages',
572 action='store', type=str,
573 nargs='+', metavar='PKG',
574 default=[],
575 help='only include selected packages in output')
576 common_options.add_argument(
577 '--local-only',
578 action='store_true',
579 default=False,
580 help='include only local packages')
581 common_options.add_argument(
582 '--user-only',
583 action='store_true',
584 default=False,
585 help='include only packages of the user site dir')
586
587 format_options.add_argument(
588 '-s', '--with-system',
589 action='store_true',
590 default=False,
591 help='dump with system packages')
592 format_options.add_argument(
593 '-a', '--with-authors',
594 action='store_true',
595 default=False,
596 help='dump with package authors')
597 format_options.add_argument(
598 '-u', '--with-urls',
599 action='store_true',
600 default=False,
601 help='dump with package urls')
602 format_options.add_argument(
603 '-d', '--with-description',
604 action='store_true',
605 default=False,
606 help='dump with short package description')
607 format_options.add_argument(
608 '-l', '--with-license-file',
609 action='store_true',
610 default=False,
611 help='dump with location of license file and '
612 'contents, most useful with JSON output')
613 format_options.add_argument(
614 '--no-license-path',
615 action='store_true',
616 default=False,
617 help='I|when specified together with option -l, '
618 'suppress location of license file output')
619 format_options.add_argument(
620 '--with-notice-file',
621 action='store_true',
622 default=False,
623 help='I|when specified together with option -l, '
624 'dump with location of license file and contents')
625 format_options.add_argument(
626 '--filter-strings',
627 action="store_true",
628 default=False,
629 help='filter input according to code page')
630 format_options.add_argument(
631 '--filter-code-page',
632 action="store", type=str,
633 default="latin1",
634 metavar="CODE",
635 help='I|specify code page for filtering '
636 '(default: %(default)s)')
637
638 verify_options.add_argument(
639 '--fail-on',
640 action='store', type=str,
641 default=None,
642 help='fail (exit with code 1) on the first occurrence '
643 'of the licenses of the semicolon-separated list')
644 verify_options.add_argument(
645 '--allow-only',
646 action='store', type=str,
647 default=None,
648 help='fail (exit with code 1) on the first occurrence '
649 'of the licenses not in the semicolon-separated list')
650
651 return parser
652
653
654 def main(): # pragma: no cover
655 parser = create_parser()
656 args = parser.parse_args()
657
658 output_string = create_output_string(args)
659
660 print(output_string)
661
662
663 if __name__ == '__main__': # pragma: no cover
664 main()

eric ide

mercurial