/
/
/
1#!/usr/bin/env python3
2"""
3Check PyPI package metadata for security and supply chain concerns.
4
5This script checks new or updated Python dependencies for suspicious indicators
6that might suggest supply chain attacks or unmaintained packages.
7"""
8
9# ruff: noqa: T201, S310, RUF001, PLR0915
10import json
11import re
12import sys
13import urllib.request
14from datetime import datetime
15from typing import Any
16
17# Complete spellings of compatible licenses, as packages write them in the free-form license
18# field and as PyPI names them in its `License ::` classifiers. A value has to be one of these
19# in full: a name occurring somewhere inside it says nothing about the terms around it.
20COMPATIBLE_LICENSE_NAMES = {
21 # names of the PyPI classifiers, which are a closed vocabulary
22 "Apache Software License",
23 "Boost Software License 1.0 (BSL-1.0)",
24 "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication",
25 "CMU License (MIT-CMU)",
26 "GNU Lesser General Public License v2 (LGPLv2)",
27 "GNU Lesser General Public License v2 or later (LGPLv2+)",
28 "GNU Lesser General Public License v3 (LGPLv3)",
29 "GNU Lesser General Public License v3 or later (LGPLv3+)",
30 "GNU Library or Lesser General Public License (LGPL)",
31 "Historical Permission Notice and Disclaimer (HPND)",
32 "ISC License (ISCL)",
33 "MIT No Attribution License (MIT-0)",
34 "Mozilla Public License 2.0 (MPL 2.0)",
35 "Public Domain",
36 "Python Software Foundation License",
37 "The Unlicense (Unlicense)",
38 "Universal Permissive License (UPL)",
39 "Zero-Clause BSD (0BSD)",
40 "zlib/libpng License",
41 # spellings of the same licenses used in the free-form field. Only versioned names of the
42 # licenses whose versions differ in what they allow, so that an unversioned "Apache" cannot
43 # stand in for Apache-1.1 or an unversioned "MPL" for the reciprocal MPL-1.1
44 "2-Clause BSD",
45 "3-Clause BSD",
46 "Apache 2",
47 "Apache 2.0",
48 "Apache License 2.0",
49 "Apache License, Version 2.0",
50 "Apache Software License 2.0",
51 "BSD",
52 "BSD-2",
53 "BSD-2-Clause",
54 "BSD-3",
55 "BSD-3-Clause",
56 "CC0",
57 "CC0 1.0",
58 "CC0 1.0 Universal",
59 "Expat",
60 "ISC",
61 "ISCL",
62 "LGPL",
63 "LGPLv2",
64 "LGPLv3",
65 "MIT",
66 "MPL 2.0",
67 "Modified BSD",
68 "New BSD",
69 "PSF",
70 "PSFL",
71 "Simplified BSD",
72 "The MIT License (MIT)",
73 "Unlicense",
74 "Zlib",
75}
76
77# The grant each permissive license spells out, used to recognise packages that put their whole
78# license text in the license field. The grant is what the license actually gives, so it
79# identifies the text where the heading above it would only be a name in prose. Each is quoted
80# far enough to cover the permission itself, so that a text restricting it reads differently.
81LICENSE_TEXT_GRANTS = {
82 # MIT
83 "Permission is hereby granted, free of charge, to any person obtaining a copy of this"
84 " software and associated documentation files",
85 # ISC
86 "Permission to use, copy, modify, and/or distribute this software for any purpose with or"
87 " without fee is hereby granted",
88 # BSD, 2-clause and 3-clause alike
89 "Redistribution and use in source and binary forms, with or without modification, are"
90 " permitted provided that the following conditions are met",
91 # Apache-2.0
92 'Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file'
93 " except in compliance with the License",
94 # MPL-2.0
95 "This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a"
96 " copy of the MPL was not distributed with this file",
97 # Unlicense
98 "This is free and unencumbered software released into the public domain. Anyone is free to"
99 " copy, modify, publish, use, compile, sell, or distribute this software",
100 # Zlib
101 "Permission is granted to anyone to use this software for any purpose, including commercial"
102 " applications, and to alter it and redistribute it freely",
103}
104
105# Words that turn the grant written behind them into its opposite, and how many words ahead of
106# the grant they are looked for
107GRANT_NEGATIONS = ("NO", "NOT", "NEITHER", "NEVER")
108GRANT_NEGATION_REACH = 3
109
110# SPDX identifiers accepted in a PEP 639 `license_expression`
111COMPATIBLE_SPDX_LICENSES = {
112 "0BSD",
113 "APACHE-2.0",
114 "BSD-2-CLAUSE",
115 "BSD-3-CLAUSE",
116 "BSL-1.0",
117 "CC0-1.0",
118 "ISC",
119 "LGPL-2.0",
120 "LGPL-2.0-ONLY",
121 "LGPL-2.0-OR-LATER",
122 "LGPL-2.1",
123 "LGPL-2.1-ONLY",
124 "LGPL-2.1-OR-LATER",
125 "LGPL-3.0",
126 "LGPL-3.0-ONLY",
127 "LGPL-3.0-OR-LATER",
128 "MIT",
129 "MIT-0",
130 "MIT-CMU",
131 "MPL-2.0",
132 "PSF-2.0",
133 "PYTHON-2.0",
134 "UNLICENSE",
135 "ZLIB",
136}
137
138# License families that are incompatible with the project (LGPL excluded)
139PROBLEMATIC_LICENSES = ("GPL", "AGPL", "SSPL")
140
141# Deepest group nesting accepted in an SPDX expression
142MAX_SPDX_NESTING = 10
143
144# Common packages to check for typosquatting (popular Python packages)
145POPULAR_PACKAGES = {
146 "requests",
147 "urllib3",
148 "setuptools",
149 "certifi",
150 "pip",
151 "numpy",
152 "pandas",
153 "boto3",
154 "botocore",
155 "awscli",
156 "django",
157 "flask",
158 "sqlalchemy",
159 "pytest",
160 "pydantic",
161 "aiohttp",
162 "fastapi",
163}
164
165
166def check_typosquatting(package_name: str) -> str | None:
167 """
168 Check if package name might be typosquatting a popular package.
169
170 :param package_name: The package name to check.
171 """
172 package_lower = package_name.lower().replace("-", "").replace("_", "")
173
174 for popular in POPULAR_PACKAGES:
175 popular_normalized = popular.lower().replace("-", "").replace("_", "")
176
177 # Check for common typosquatting techniques
178 if package_lower == popular_normalized:
179 continue # Exact match is fine
180
181 # Check edit distance (1-2 character changes)
182 if len(package_lower) == len(popular_normalized):
183 differences = sum(
184 c1 != c2 for c1, c2 in zip(package_lower, popular_normalized, strict=True)
185 )
186 if differences == 1:
187 return f"Suspicious: Very similar to popular package '{popular}'"
188
189 # Check for common substitutions
190 substitutions = [
191 ("0", "o"),
192 ("1", "l"),
193 ("1", "i"),
194 ]
195 for old, new in substitutions:
196 if old in package_lower:
197 test_name = package_lower.replace(old, new)
198 if test_name == popular_normalized:
199 return f"Suspicious: Character substitution of popular package '{popular}'"
200
201 return None
202
203
204def get_package_license(info: dict[str, Any]) -> tuple[str, bool]:
205 """
206 Resolve the license of a package from its PyPI metadata.
207
208 Returns the license and whether it is a PEP 639 SPDX expression.
209
210 :param info: The `info` section of the PyPI JSON response.
211 """
212 # packages that adopted PEP 639 declare an SPDX expression, which is more precise than the
213 # free-form field and the classifiers, and is usually the only license metadata they carry
214 if license_expression := (info.get("license_expression") or "").strip():
215 return license_expression, True
216
217 if license_str := (info.get("license") or "").strip():
218 return license_str, False
219
220 license_classifiers = []
221 for classifier in info.get("classifiers") or []:
222 # the license itself is the last segment, e.g. "License :: OSI Approved :: MIT License",
223 # except for "License :: OSI Approved", which names no license at all
224 parts = [part.strip() for part in classifier.split("::")]
225 if parts[0] == "License" and len(parts) > 1 and parts[-1] != "OSI Approved":
226 license_classifiers.append(parts[-1])
227
228 if license_classifiers:
229 # classifiers do not say how they relate to each other, so report the one that fails the
230 # compatibility check rather than letting a permissive entry hide a copyleft one
231 return next(
232 (name for name in license_classifiers if not check_license_compatibility(name)[0]),
233 license_classifiers[0],
234 ), False
235
236 return "Unknown", False
237
238
239def check_license_compatibility(
240 license_str: str, spdx_expression: bool = False
241) -> tuple[bool, str]:
242 """
243 Check if license is compatible with the project.
244
245 :param license_str: The license string from PyPI.
246 :param spdx_expression: Whether the string is a PEP 639 SPDX expression.
247 """
248 if not license_str or license_str == "Unknown":
249 return False, "No license information"
250
251 license_upper = license_str.upper()
252 spdx_compatible = _evaluate_spdx_expression(license_str)
253
254 if spdx_compatible:
255 return True, f"Compatible ({license_str})"
256
257 copyleft = _is_copyleft(license_upper)
258
259 # whatever the evaluator did not accept in an expression names a license that is not on the
260 # allow list, so reading such a value further would only weaken the check. A free-form field
261 # is all we have to go on, but what we recognise in it must not end up approving a copyleft
262 # license standing next to it, and a "LicenseRef-" value names a custom license whatever
263 # wording is packed into the identifier
264 custom = "LICENSEREF" in license_upper
265 if spdx_compatible is None and not copyleft and not custom and not spdx_expression:
266 # a free-form field holds either the name of the license or the text of it; a name says
267 # nothing about terms written around it, and a text is read on the grant it spells out
268 if _names_license(license_str) or _quotes_license_text(license_str):
269 return True, f"Compatible ({license_str})"
270
271 if copyleft:
272 return False, f"Incompatible copyleft license ({license_str})"
273
274 # Unknown license
275 return False, f"Unknown/unverified license ({license_str})"
276
277
278def parse_requirement(line: str) -> str | None:
279 """
280 Extract package name from a requirement line.
281
282 :param line: A line from requirements.txt (e.g., "package==1.0.0" or "package>=1.0")
283 """
284 line = line.strip()
285 if not line or line.startswith("#"):
286 return None
287
288 # Handle various requirement formats
289 # package==1.0.0, package>=1.0, package[extra]>=1.0, etc.
290 match = re.match(r"^([a-zA-Z0-9_-]+)", line)
291 if match:
292 return match.group(1).lower()
293 return None
294
295
296def get_pypi_metadata(package_name: str) -> dict[str, Any] | None:
297 """
298 Fetch package metadata from PyPI JSON API.
299
300 :param package_name: The name of the package to check.
301 """
302 url = f"https://pypi.org/pypi/{package_name}/json"
303
304 try:
305 with urllib.request.urlopen(url, timeout=10) as response:
306 return json.loads(response.read())
307 except urllib.error.HTTPError as err:
308 if err.code == 404:
309 print(f"â Package '{package_name}' not found on PyPI")
310 else:
311 print(f"â ï¸ Error fetching metadata for '{package_name}': {err}")
312 return None
313 except Exception as err:
314 print(f"â ï¸ Error fetching metadata for '{package_name}': {err}")
315 return None
316
317
318def check_package(package_name: str) -> dict[str, Any]:
319 """
320 Check a single package for security concerns.
321
322 :param package_name: The name of the package to check.
323 """
324 data = get_pypi_metadata(package_name)
325
326 if not data:
327 return {
328 "name": package_name,
329 "error": "Could not fetch package metadata",
330 "risk_level": "unknown",
331 "warnings": [],
332 }
333
334 info = data.get("info", {})
335 releases = data.get("releases", {})
336
337 # Get package age
338 upload_times = []
339 for release_files in releases.values():
340 if release_files:
341 for file_info in release_files:
342 if "upload_time" in file_info:
343 try:
344 upload_time_str = file_info["upload_time"]
345 # Handle both formats: with 'Z' suffix or with timezone
346 if upload_time_str.endswith("Z"):
347 upload_time_str = upload_time_str[:-1] + "+00:00"
348 upload_time = datetime.fromisoformat(upload_time_str)
349 upload_times.append(upload_time)
350 except ValueError, AttributeError:
351 continue
352
353 first_upload = min(upload_times) if upload_times else None
354 age_days = (datetime.now(first_upload.tzinfo) - first_upload).days if first_upload else 0
355
356 # Extract metadata
357 project_urls = info.get("project_urls") or {}
358 homepage = info.get("home_page") or project_urls.get("Homepage")
359 source = project_urls.get("Source") or project_urls.get("Repository")
360
361 # Run automated security checks
362 typosquat_check = check_typosquatting(package_name)
363 package_license, spdx_expression = get_package_license(info)
364 license_compatible, license_status = check_license_compatibility(
365 package_license, spdx_expression
366 )
367
368 checks = {
369 "name": package_name,
370 "version": info.get("version", "unknown"),
371 "age_days": age_days,
372 "total_releases": len(releases),
373 "has_homepage": bool(homepage),
374 "has_source": bool(source),
375 "author": info.get("author") or info.get("maintainer") or "Unknown",
376 "license": package_license,
377 "summary": info.get("summary", "No description"),
378 "warnings": [],
379 "info_items": [],
380 "risk_level": "low",
381 "automated_checks": {
382 "trusted_source": bool(source),
383 "typosquatting": typosquat_check is None,
384 "license_compatible": license_compatible,
385 },
386 "check_details": {
387 "typosquatting": typosquat_check or "â No typosquatting detected",
388 "license": license_status,
389 },
390 }
391
392 # Check for suspicious indicators
393 risk_score = 0
394
395 # Typosquatting check
396 if typosquat_check:
397 checks["warnings"].append(typosquat_check)
398 risk_score += 5 # High risk
399
400 # License check
401 if not license_compatible:
402 checks["warnings"].append(f"License issue: {license_status}")
403 risk_score += 2
404
405 if age_days < 30:
406 checks["warnings"].append(f"Very new package (only {age_days} days old)")
407 risk_score += 3
408 elif age_days < 90:
409 checks["warnings"].append(f"Relatively new package ({age_days} days old)")
410 risk_score += 1
411
412 if checks["total_releases"] < 3:
413 checks["warnings"].append(f"Very few releases (only {checks['total_releases']})")
414 risk_score += 2
415
416 if not source:
417 checks["warnings"].append("No source repository linked")
418 risk_score += 2
419
420 if not homepage and not source:
421 checks["warnings"].append("No homepage or source repository")
422 risk_score += 1
423
424 if checks["author"] == "Unknown":
425 checks["warnings"].append("No author information available")
426 risk_score += 1
427
428 # Add informational items
429 checks["info_items"].append(f"Age: {age_days} days")
430 checks["info_items"].append(f"Releases: {checks['total_releases']}")
431 checks["info_items"].append(f"Author: {checks['author']}")
432 checks["info_items"].append(f"License: {checks['license']}")
433 if source:
434 checks["info_items"].append(f"Source: {source}")
435
436 # Determine risk level
437 if risk_score >= 5:
438 checks["risk_level"] = "high"
439 elif risk_score >= 3:
440 checks["risk_level"] = "medium"
441 else:
442 checks["risk_level"] = "low"
443
444 return checks
445
446
447def format_check_result(result: dict[str, Any]) -> str:
448 """
449 Format a check result for display.
450
451 :param result: The check result dictionary.
452 """
453 risk_emoji = {"high": "ð´", "medium": "ð¡", "low": "ð¢", "unknown": "âª"}
454 version = result.get("version", "unknown")
455
456 lines = [f"\n{risk_emoji[result['risk_level']]} **{result['name']}** (v{version})"]
457
458 if result.get("error"):
459 lines.append(f" â {result['error']}")
460 return "\n".join(lines)
461
462 if result.get("summary"):
463 lines.append(f" ð {result['summary']}")
464
465 if result.get("info_items"):
466 for item in result["info_items"]:
467 lines.append(f" â¹ï¸ {item}")
468
469 if result.get("warnings"):
470 for warning in result["warnings"]:
471 lines.append(f" â ï¸ {warning}")
472
473 return "\n".join(lines)
474
475
476def main() -> int:
477 """Run the package safety check."""
478 if len(sys.argv) < 2:
479 print("Usage: check_package_safety.py <requirements_file_or_package_name>")
480 print(" Or: check_package_safety.py package1 package2 package3")
481 return 1
482
483 packages = []
484
485 # Check if first argument is a file
486 if len(sys.argv) == 2 and sys.argv[1].endswith(".txt"):
487 try:
488 with open(sys.argv[1]) as f:
489 for line in f:
490 package = parse_requirement(line)
491 if package:
492 packages.append(package)
493 except FileNotFoundError:
494 print(f"Error: File '{sys.argv[1]}' not found")
495 return 1
496 else:
497 # Treat arguments as package names
498 packages = [arg.lower() for arg in sys.argv[1:]]
499
500 if not packages:
501 print("No packages to check")
502 return 0
503
504 print(f"Checking {len(packages)} package(s)...\n")
505 print("=" * 80)
506
507 results = []
508 for package in packages:
509 result = check_package(package)
510 results.append(result)
511 print(format_check_result(result))
512
513 print("\n" + "=" * 80)
514
515 # Automated checks summary
516 all_trusted = all(r.get("automated_checks", {}).get("trusted_source", False) for r in results)
517 all_no_typosquat = all(
518 r.get("automated_checks", {}).get("typosquatting", False) for r in results
519 )
520 all_license_ok = all(
521 r.get("automated_checks", {}).get("license_compatible", False) for r in results
522 )
523
524 print("\nð¤ Automated Security Checks:")
525 trusted_msg = (
526 "All packages have source repositories"
527 if all_trusted
528 else "Some packages missing source info"
529 )
530 print(f" {'â
' if all_trusted else 'â'} Trusted Sources: {trusted_msg}")
531
532 typosquat_msg = (
533 "No suspicious package names detected"
534 if all_no_typosquat
535 else "Possible typosquatting detected!"
536 )
537 print(f" {'â
' if all_no_typosquat else 'â'} Typosquatting: {typosquat_msg}")
538
539 license_msg = (
540 "All licenses are compatible" if all_license_ok else "Some license issues detected"
541 )
542 print(f" {'â
' if all_license_ok else 'â ï¸ '} License Compatibility: {license_msg}")
543
544 # Summary
545 high_risk = sum(1 for r in results if r["risk_level"] == "high")
546 medium_risk = sum(1 for r in results if r["risk_level"] == "medium")
547 low_risk = sum(1 for r in results if r["risk_level"] == "low")
548
549 print(f"\nð Summary: {len(results)} packages checked")
550 if high_risk:
551 print(f" ð´ High risk: {high_risk}")
552 if medium_risk:
553 print(f" ð¡ Medium risk: {medium_risk}")
554 print(f" ð¢ Low risk: {low_risk}")
555
556 if high_risk > 0:
557 print("\nâ ï¸ High-risk packages detected! Manual review strongly recommended.")
558 return 2
559 if medium_risk > 0:
560 print("\nâ ï¸ Medium-risk packages detected. Please review before merging.")
561 return 1
562
563 print("\nâ
All packages passed basic safety checks.")
564 return 0
565
566
567class _SpdxSyntaxError(Exception):
568 """Raised when a string does not follow the SPDX expression grammar."""
569
570
571def _evaluate_spdx_expression(license_str: str) -> bool | None:
572 """
573 Check an SPDX license expression (PEP 639) against the allow list.
574
575 Returns whether the expression is compatible, which is None when it names a license that is
576 neither known-compatible nor known-problematic or when the string is not an expression at all.
577
578 :param license_str: The license string to evaluate, e.g. "MIT OR Apache-2.0".
579 """
580 tokens = re.findall(r"\(|\)|[^\s()]+", license_str)
581 if not tokens:
582 return None
583 # real expressions nest a group or two at most; refuse anything deeper rather than recursing
584 # into it, and refuse outright as the fallback would match on a name nested inside
585 if _max_group_depth(tokens) > MAX_SPDX_NESTING:
586 return False
587
588 remaining = list(tokens)
589 try:
590 result = _evaluate_spdx_tokens(remaining)
591 if remaining:
592 # anything left over means we did not understand the string as a whole
593 raise _SpdxSyntaxError
594 except _SpdxSyntaxError:
595 result = None
596
597 return result
598
599
600def _evaluate_spdx_tokens(tokens: list[str]) -> bool | None:
601 """
602 Evaluate the leading SPDX expression, consuming the tokens it covers.
603
604 Any alternative that is compatible makes the whole expression compatible.
605
606 :param tokens: The remaining tokens of the expression.
607 """
608 result = _evaluate_spdx_term(tokens)
609 while tokens and tokens[0].upper() == "OR":
610 tokens.pop(0)
611 term = _evaluate_spdx_term(tokens)
612 if True in (result, term):
613 result = True
614 elif None in (result, term):
615 result = None
616 return result
617
618
619def _evaluate_spdx_term(tokens: list[str]) -> bool | None:
620 """
621 Evaluate the leading "AND" sequence, which binds tighter than "OR" in SPDX.
622
623 Every operand of the sequence has to be compatible on its own.
624
625 :param tokens: The remaining tokens of the expression.
626 """
627 result = _evaluate_spdx_operand(tokens)
628 while tokens and tokens[0].upper() == "AND":
629 tokens.pop(0)
630 operand = _evaluate_spdx_operand(tokens)
631 if False in (result, operand):
632 result = False
633 elif None in (result, operand):
634 result = None
635 return result
636
637
638def _evaluate_spdx_operand(tokens: list[str]) -> bool | None:
639 """
640 Evaluate a single SPDX operand: a parenthesised expression or a license identifier.
641
642 :param tokens: The remaining tokens of the expression.
643 """
644 if not tokens or _is_spdx_operator(tokens[0]):
645 raise _SpdxSyntaxError
646
647 if tokens[0] == "(":
648 tokens.pop(0)
649 result = _evaluate_spdx_tokens(tokens)
650 if not tokens or tokens.pop(0) != ")":
651 raise _SpdxSyntaxError
652 return result
653
654 # a single trailing "+" is the deprecated "or later" marker and does not change the license
655 identifier = tokens.pop(0).upper().removesuffix("+")
656 # a "WITH <exception>" suffix only grants extra permissions, so the identifier decides
657 if tokens and tokens[0].upper() == "WITH":
658 tokens.pop(0)
659 if not tokens or _is_spdx_operator(tokens[0]):
660 raise _SpdxSyntaxError
661 # ...but a license we refuse is not an exception, whatever it is written behind
662 if _is_copyleft(tokens.pop(0).upper()):
663 return False
664
665 return True if identifier in COMPATIBLE_SPDX_LICENSES else None
666
667
668def _names_license(license_str: str) -> bool:
669 """
670 Return whether a license string is, as a whole, one or more licenses we accept.
671
672 :param license_str: The license string to read, e.g. "MIT License".
673 """
674 known = {_license_name_words(name) for name in COMPATIBLE_LICENSE_NAMES}
675 if _license_name_words(license_str) in known:
676 return True
677
678 # a value naming several licenses, whether it combines them or offers a choice, is accepted
679 # only when every one of them is. The whole value is matched first, so that a name holding a
680 # separator is not taken apart into pieces that name nothing
681 names = [
682 _license_name_words(name)
683 for name in re.split(r"[,/]|\band\b|\bor\b", license_str, flags=re.IGNORECASE)
684 if name.strip()
685 ]
686 return bool(names) and known.issuperset(names)
687
688
689def _quotes_license_text(license_str: str) -> bool:
690 """
691 Return whether a license string spells out the text of a license we accept.
692
693 :param license_str: The license string to read.
694 """
695 words = f" {_license_words(license_str)} "
696 for grant in LICENSE_TEXT_GRANTS:
697 before, found, _ = words.partition(f" {_license_words(grant)} ")
698 # a grant the text denies is not one the license gives
699 preceding = before.rsplit(maxsplit=GRANT_NEGATION_REACH)[-GRANT_NEGATION_REACH:]
700 if found and not any(word in GRANT_NEGATIONS for word in preceding):
701 return True
702
703 return False
704
705
706def _license_name_words(name: str) -> str:
707 """
708 Return the words of a license name as one key, so spellings of it compare equal.
709
710 :param name: The license name to normalise, e.g. "MPL 2.0".
711 """
712 # a leading "The" and a trailing "License" are noise the same name is written with and without
713 words = _license_words(name).split()
714 if words[:1] == ["THE"]:
715 del words[0]
716 if words[-1:] == ["LICENSE"]:
717 del words[-1]
718 return " ".join(words)
719
720
721def _license_words(value: str) -> str:
722 """
723 Return the words of a license string as one key, so spelling variants compare equal.
724
725 :param value: The license string to normalise.
726 """
727 # separators are spelled inconsistently ("MPL 2.0" for "MPL-2.0") and "licence" and "license"
728 # are the same word. Words in any script count, so that a term we cannot read keeps the value
729 # from matching a name rather than dropping out of it
730 return " ".join(re.findall(r"[^\W_]+", value.upper().replace("LICENCE", "LICENSE")))
731
732
733def _is_spdx_operator(token: str) -> bool:
734 """
735 Return whether a token joins or closes expressions instead of naming a license.
736
737 :param token: The token to inspect.
738 """
739 return token == ")" or token.upper() in ("AND", "OR", "WITH")
740
741
742def _is_copyleft(license_upper: str) -> bool:
743 """
744 Return whether an upper-cased license string names a copyleft family we do not accept.
745
746 :param license_upper: The upper-cased license string to inspect.
747 """
748 # drop the LGPL mentions first, so that a GPL term next to an LGPL one is still spotted
749 without_lgpl = license_upper.replace("LGPL", "")
750 return any(problem in without_lgpl for problem in PROBLEMATIC_LICENSES)
751
752
753def _max_group_depth(tokens: list[str]) -> int:
754 """
755 Return how deeply the parentheses in an expression nest.
756
757 :param tokens: The tokens of the expression.
758 """
759 depth = 0
760 deepest = 0
761 for token in tokens:
762 if token == "(":
763 depth += 1
764 deepest = max(deepest, depth)
765 elif token == ")":
766 depth = max(0, depth - 1)
767 return deepest
768
769
770if __name__ == "__main__":
771 sys.exit(main())
772