Skip to content

Commit 0a154eb

Browse files
PATCH -- Workaround for multiple files (inspired by, yet, Supercedes PR #346) (- WIP #71 & #242 -)
🎉 A big, heart-felt thank you to @stefan6419846 for their work on stefan6419846/pip-licenses-cli#32 and for also reviewing #346 (upon which this workaround is closely based) Implements some upcoming flags (plural) and contributes to supporting for multiple licenses in a single package for JSON and plain-vertical formats (but not others, e.g., HTML, etc.) **IMPORTANT** This workaround for GHI-71 (and related GHI-242) is ported from the Alpha-v6.0.0 branch and includes portions of PR #346 that was inspired by stefan6419846/pip-licenses-cli#32 (and thus SHOULD NOT be included as is in a release) See also: stefan6419846/pip-licenses-cli#32
1 parent 7479703 commit 0a154eb

5 files changed

Lines changed: 445 additions & 79 deletions

File tree

pip-licenses/__init__.py

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,14 +191,64 @@ def deduplicate_and_normalize(
191191
)
192192

193193

194+
def _handle_multiple_value_field(
195+
key: str, value: Iterator[str]
196+
) -> str | list[str]:
197+
"""Normalize a metadata field that may contain one or many values.
198+
199+
This helper converts an iterator of field values into the most convenient
200+
representation based on the field name:
201+
202+
- If the field name ends with ``"s"`` (case-insensitive), the values are
203+
treated as plural and returned as a list.
204+
- Otherwise, the first value is returned as a single string.
205+
- If a plural field has no values, ``["UNKNOWN"]`` is returned.
206+
- If a singular field has no values, ``LICENSE_UNKNOWN`` is returned.
207+
208+
Args:
209+
key: The field name used to decide whether the field should be treated
210+
as singular or plural.
211+
value: An iterator of string values for the field.
212+
213+
Returns:
214+
Either:
215+
- a list of strings for plural fields, or
216+
- a single string for singular fields.
217+
218+
Examples:
219+
A plural field returns all values as a list:
220+
221+
>>> _handle_multiple_value_field("authors", iter(["Alice", "Bob"]))
222+
['Alice', 'Bob']
223+
224+
A singular field returns the first value:
225+
226+
>>> _handle_multiple_value_field("license", iter(["MIT", "BSD"]))
227+
'MIT'
228+
229+
An empty plural field falls back to ``["UNKNOWN"]``:
230+
231+
>>> _handle_multiple_value_field("authors", iter([]))
232+
['UNKNOWN']
233+
234+
An empty singular field falls back to ``LICENSE_UNKNOWN``:
235+
236+
>>> _handle_multiple_value_field("license", iter([]))
237+
'UNKNOWN'
238+
"""
239+
if key.lower().endswith("s"):
240+
return list(value) or ["UNKNOWN"]
241+
return cast(str, next(value, LICENSE_UNKNOWN))
242+
243+
194244
def create_licenses_table(
195245
args: CustomNamespace,
196246
output_fields: set[str] | Sequence[str] = DEFAULT_OUTPUT_FIELDS,
197247
) -> PrettyTable:
198248
table = factory_styled_table_with_args(args, output_fields)
199249

200250
for pkg in get_packages(args):
201-
row = []
251+
row: list[str | list[str]] = []
202252
for field in output_fields:
203253
if field == "License":
204254
license_set = select_license_by_source(
@@ -207,17 +257,52 @@ def create_licenses_table(
207257
cast(str, pkg["license"]),
208258
cast(str, pkg["license_expression"]),
209259
)
210-
license_str = "; ".join(sorted(license_set))
260+
_sorted_license_set = (
261+
sorted(license_set) if license_set else []
262+
)
263+
_normalized_license_set = {
264+
normal_item
265+
for normal_item in _sorted_license_set
266+
if normal_item is not None
267+
}
268+
license_str = "; ".join(_normalized_license_set)
211269
row.append(license_str)
212270
elif field == "License-Classifier":
213271
row.append(
214272
"; ".join(sorted(pkg["license_classifier"]))
215273
or LICENSE_UNKNOWN
216274
)
217-
elif field.lower() in pkg:
275+
elif field == "License-Expression":
276+
row.append(
277+
cast(str, pkg["license_expression"]) or LICENSE_UNKNOWN
278+
)
279+
elif field == "License-Metadata":
280+
row.append(cast(str, pkg["license"]) or LICENSE_UNKNOWN)
281+
elif (field.lower() in pkg) or (hasattr(pkg, field.lower())):
218282
row.append(cast(str, pkg[field.lower()]))
219283
else:
220-
row.append(cast(str, pkg[FIELDS_TO_METADATA_KEYS[field]]))
284+
if (field in FIELDS_TO_METADATA_KEYS) and (
285+
FIELDS_TO_METADATA_KEYS[field] in pkg
286+
):
287+
value = pkg[FIELDS_TO_METADATA_KEYS[field]]
288+
if value:
289+
if field in _MULTI_VALUE_KEYS:
290+
row.append(
291+
cast(
292+
list[str],
293+
_handle_multiple_value_field(
294+
key=field,
295+
value=cast(Iterator[str], [*value]),
296+
),
297+
)
298+
)
299+
else:
300+
row.append(cast(str, value))
301+
else: # invalid value (e.g. None)
302+
row.append(LICENSE_UNKNOWN)
303+
else: # Unknown value (e.g. custom/future fields)
304+
row.append(LICENSE_UNKNOWN)
305+
221306
table.add_row(row)
222307

223308
return table

pip-licenses/cli/__init__.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,23 @@ def parse_args( # type: ignore[override]
163163
return args_
164164

165165
def _verify_args(self, args: CustomNamespace) -> None:
166-
if args.with_license_file is False and (
167-
args.no_license_path is True or args.with_notice_file is True
166+
if (
167+
args.with_license_file is False
168+
and args.with_license_files is False
169+
) and (
170+
args.no_license_path is True
171+
or (
172+
(
173+
args.with_notice_file is True
174+
or args.with_notice_files is True
175+
)
176+
or args.with_other_files is True
177+
)
168178
):
169179
self.error(
170-
"'--no-license-path' and '--with-notice-file' require "
171-
"the '--with-license-file' option to be set"
180+
"'--no-license-path' and '--with-notice-file[s]' "
181+
"as well as '--with-other-files' require "
182+
"the '--with-license-file[s]' option to be set"
172183
)
173184
if args.filter_strings is False and args.filter_code_page != "latin1":
174185
self.error(
@@ -238,6 +249,7 @@ def create_parser(
238249
config_from_file = load_config_from_file(pyproject_path)
239250

240251
common_options = parser.add_argument_group("Common options")
252+
license_file_options = parser.add_argument_group("License file options")
241253
format_options = parser.add_argument_group("Format options")
242254
verify_options = parser.add_argument_group("Verify options")
243255

@@ -379,7 +391,8 @@ def create_parser(
379391
default=config_from_file.get("no-version", False),
380392
help="dump without package version",
381393
)
382-
format_options.add_argument(
394+
395+
license_file_options.add_argument(
383396
"-l",
384397
"--with-license-file",
385398
action="store_true",
@@ -389,19 +402,46 @@ def create_parser(
389402
"For structured formats (CSV, Markdown, reST), "
390403
"see README for workflow examples.",
391404
)
392-
format_options.add_argument(
405+
license_file_options.add_argument(
406+
"--with-license-files",
407+
action="store_true",
408+
default=config_from_file.get("with-license-files", False),
409+
help="dump with location of each license file and contents, most useful with JSON output",
410+
)
411+
license_file_options.add_argument(
393412
"--no-license-path",
394413
action="store_true",
395414
default=config_from_file.get("no-license-path", False),
396415
help="I|when specified together with option -l, "
397416
"suppress location of license file output",
398417
)
399-
format_options.add_argument(
418+
license_file_options.add_argument(
419+
"--no-file-paths",
420+
action="store_true",
421+
default=config_from_file.get("no-file-paths", False),
422+
help="I|Suppress location of file path outputs",
423+
)
424+
license_file_options.add_argument(
400425
"--with-notice-file",
401426
action="store_true",
402427
default=config_from_file.get("with-notice-file", False),
403428
help="I|when specified together with option -l, "
404-
"dump with location of license file and contents",
429+
"dump with location of up to one notice file and contents",
430+
)
431+
license_file_options.add_argument(
432+
"--with-notice-files",
433+
action="store_true",
434+
default=config_from_file.get("with-notice-files", False),
435+
help="I|when specified together with option -l, "
436+
"dump with location of all notice files and contents",
437+
)
438+
license_file_options.add_argument(
439+
"--with-other-files",
440+
action="store_true",
441+
default=config_from_file.get("with-other-files", False),
442+
help="I|when specified together with option -l"
443+
" or --with-license-files, dump with location"
444+
" of other licensing-related files and contents",
405445
)
406446
format_options.add_argument(
407447
"--filter-strings",

pip-licenses/cli/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ class CustomNamespace(argparse.Namespace):
5656
with_urls: bool
5757
with_description: bool
5858
with_license_file: bool
59+
with_license_files: bool # added in v6.0
5960
no_license_path: bool
6061
with_notice_file: bool
62+
with_notice_files: bool # added in v6.0
63+
with_other_files: bool # added in v6.0
6164
filter_strings: bool
6265
filter_code_page: str
6366
partial_match: bool

0 commit comments

Comments
 (0)