/
/
/
1"""Tests for the package safety script."""
2
3from __future__ import annotations
4
5from typing import Any
6
7import pytest
8
9from scripts import check_package_safety
10from scripts.check_package_safety import (
11 check_license_compatibility,
12 check_package,
13 check_typosquatting,
14 get_package_license,
15)
16
17
18def pypi_info(**overrides: Any) -> dict[str, Any]:
19 """
20 Return the `info` section of a PyPI JSON response.
21
22 :param overrides: Fields to set on top of the (empty) license metadata.
23 """
24 return {"license": None, "license_expression": None, "classifiers": [], **overrides}
25
26
27def test_typosquatting_allows_exact_popular_package() -> None:
28 """Test an exact popular package name is not reported as typosquatting."""
29 assert check_typosquatting("requests") is None
30
31
32def test_typosquatting_detects_single_character_change() -> None:
33 """Test an equal-length package name with one changed character is reported."""
34 assert (
35 check_typosquatting("requestz") == "Suspicious: Very similar to popular package 'requests'"
36 )
37
38
39@pytest.mark.parametrize(
40 ("package_name", "popular_package"),
41 [
42 ("b0t0c0re", "botocore"),
43 ("sq1a1chemy", "sqlalchemy"),
44 ("cert1f1", "certifi"),
45 ],
46)
47def test_typosquatting_detects_character_substitution(
48 package_name: str, popular_package: str
49) -> None:
50 """Test common character substitutions in popular package names are reported."""
51 assert check_typosquatting(package_name) == (
52 f"Suspicious: Character substitution of popular package '{popular_package}'"
53 )
54
55
56@pytest.mark.parametrize(
57 ("info", "expected"),
58 [
59 # PEP 639: the SPDX expression is the only license metadata (chardet 7.6.0)
60 (pypi_info(license_expression="0BSD"), "0BSD"),
61 (pypi_info(license_expression="MIT OR Apache-2.0"), "MIT OR Apache-2.0"),
62 # the SPDX expression wins over the less precise legacy field and classifiers
63 (
64 pypi_info(
65 license_expression="AGPL-3.0-only",
66 classifiers=["License :: OSI Approved :: GNU Affero General Public License v3"],
67 ),
68 "AGPL-3.0-only",
69 ),
70 # packages without an SPDX expression fall back to those
71 (pypi_info(license="Apache-2.0"), "Apache-2.0"),
72 (pypi_info(classifiers=["License :: OSI Approved :: MIT License"]), "MIT License"),
73 (
74 pypi_info(
75 license="Apache-2.0",
76 classifiers=["License :: OSI Approved :: Apache Software License"],
77 ),
78 "Apache-2.0",
79 ),
80 (
81 pypi_info(classifiers=["Programming Language :: Python", "License :: OSI Approved"]),
82 "Unknown",
83 ),
84 # a classifier that is no more than the "License" segment names one no more than that does
85 (pypi_info(classifiers=["License"]), "Unknown"),
86 # several classifiers: the one that fails the check decides, whatever its position
87 (
88 pypi_info(
89 classifiers=[
90 "License :: OSI Approved :: MIT License",
91 "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
92 ]
93 ),
94 "GNU General Public License v3 (GPLv3)",
95 ),
96 (
97 pypi_info(
98 classifiers=[
99 "License :: OSI Approved :: Apache Software License",
100 "License :: OSI Approved :: MIT License",
101 ]
102 ),
103 "Apache Software License",
104 ),
105 # two-part classifiers name a license too, and must not be hidden by a permissive one
106 (
107 pypi_info(
108 classifiers=[
109 "License :: Other/Proprietary License",
110 "License :: OSI Approved :: MIT License",
111 ]
112 ),
113 "Other/Proprietary License",
114 ),
115 (pypi_info(classifiers=["License :: Public Domain"]), "Public Domain"),
116 # nothing at all to go on
117 (pypi_info(), "Unknown"),
118 (pypi_info(license=" "), "Unknown"),
119 ],
120)
121def test_get_package_license(info: dict[str, Any], expected: str) -> None:
122 """Test the license is resolved from any of the fields PyPI exposes it in."""
123 assert get_package_license(info)[0] == expected
124
125
126@pytest.mark.parametrize(
127 ("info", "expected"),
128 [
129 (pypi_info(license_expression="0BSD"), True),
130 (pypi_info(license="0BSD"), False),
131 (pypi_info(classifiers=["License :: OSI Approved :: MIT License"]), False),
132 (pypi_info(), False),
133 ],
134)
135def test_get_package_license_reports_spdx(info: dict[str, Any], expected: bool) -> None:
136 """Test only a PEP 639 expression is reported as one."""
137 assert get_package_license(info)[1] is expected
138
139
140@pytest.mark.parametrize(
141 ("license_str", "expected"),
142 [
143 # an expression is validated by PyPI, so what the evaluator rejects is simply not allowed,
144 # rather than wording we failed to read
145 ("MIT AND Frobnicate-1.0", False),
146 # a malformed expression names nothing we can check, so it is not compatible either
147 ("MIT OR AND", False),
148 ("MIT WITH OR", False),
149 # "or later" is a single marker, not a way to dress up an unknown identifier
150 ("MIT++++", False),
151 ("LGPL-2.1+", True),
152 ("MIT OR (Apache-2.0", False),
153 ("BSD-3-Clause-No-Nuclear-License-2014", False),
154 ("LicenseRef-Proprietary", False),
155 # an expression is never license prose, so a custom identifier cannot smuggle in the
156 # wording of a grant to be read as the license it belongs to
157 (
158 "LicenseRef-Permission-is-hereby-granted-free-of-charge-to-any-person-obtaining-a"
159 "-copy-of-this-software-and-associated-documentation-files",
160 False,
161 ),
162 ("0BSD", True),
163 ("MIT OR Apache-2.0", True),
164 # a group has to be closed for what follows it to be read as part of the expression
165 ("(MIT) OR (Apache-2.0)", True),
166 # an alternative we do not know does not spoil one we do
167 ("Frobnicate-1.0 OR MIT", True),
168 ("Apache-2.0 WITH LLVM-exception", True),
169 ],
170)
171def test_spdx_expressions_are_not_guessed_at(license_str: str, expected: bool) -> None:
172 """Test an SPDX expression is judged on its identifiers only."""
173 assert check_license_compatibility(license_str, True)[0] is expected
174
175
176@pytest.mark.parametrize(
177 "license_str",
178 [
179 # SPDX identifiers as used in a PEP 639 expression
180 "0BSD",
181 "MIT",
182 "MIT-0",
183 "Apache-2.0",
184 "BSD-3-Clause",
185 "MPL-2.0",
186 "LGPL-2.1-or-later",
187 "MIT OR Apache-2.0",
188 "Apache-2.0 OR BSD-3-Clause",
189 "BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0",
190 "MPL-2.0 AND (Apache-2.0 OR MIT)",
191 "Apache-2.0 AND Apache-2.0 WITH LLVM-exception AND BSD-2-Clause AND MIT",
192 # legacy license strings and classifier names keep working
193 "BSD",
194 "MIT License",
195 "Apache Software License",
196 "GNU Lesser General Public License v3 (LGPLv3)",
197 "ISC License (ISCL)",
198 "PSFL",
199 "LGPLv2+",
200 "Public Domain",
201 # a plain license field can hold an expression too (aiohttp publishes this one)
202 "Apache-2.0 AND MIT",
203 "The MIT License (MIT)",
204 "CC0 1.0 Universal",
205 # spelling variants of the same licenses
206 "MPL 2.0",
207 "Apache 2.0 License",
208 "MIT license",
209 "The MIT License",
210 "MIT Licence",
211 # a name holding a comma is matched whole, before the value is read as a list
212 "Apache License, Version 2.0",
213 # spellings the BSD family is published under (protobuf, jsonpatch)
214 "3-Clause BSD License",
215 "Modified BSD License",
216 # every license of a value that lists several (pycryptodome publishes this one)
217 "BSD, Public Domain",
218 # ...however the value joins them (uritemplate publishes the first)
219 "BSD 3-Clause OR Apache-2.0",
220 "MIT/Apache-2.0",
221 "MIT License AND Apache Software License",
222 "MIT and/or Apache-2.0",
223 # a name holding a separator is still matched whole, before the value is split on one
224 "zlib/libpng License",
225 "GNU Library or Lesser General Public License (LGPL)",
226 "Historical Permission Notice and Disclaimer (HPND)",
227 # a custom license alongside one we accept still leaves a usable option
228 "MIT OR LicenseRef-Proprietary",
229 # an exception only widens what the license allows, so the license itself decides
230 "Zlib WITH LLVM-exception",
231 "LGPL-3.0-only WITH LGPL-3.0-linking-exception",
232 # packages that put their whole license text in the field are read on the grant it
233 # spells out, whatever heading and punctuation surround it (ya-dialogs-api, aiomusiccast)
234 "MIT License\n\n Copyright (c) 2026 Mikhail Nevskiy\n\n Permission is"
235 " hereby granted, free of charge, to any person obtaining a copy\n of this"
236 ' software and associated documentation files (the "Software"), to deal\n in the'
237 " Software without restriction.",
238 "**The MIT License (MIT)** Copyright © 2021, Tom Schneider Permission is hereby"
239 " granted, free of charge, to any person obtaining a copy of this software and"
240 ' associated documentation files (the "Software"), to deal in the Software without'
241 " restriction.",
242 "Copyright (c) 2026\n\nPermission to use, copy, modify, and/or distribute this software"
243 " for any purpose with or without fee is hereby granted.",
244 "Redistribution and use in source and binary forms, with or without modification, are"
245 " permitted provided that the following conditions are met.",
246 'Licensed under the Apache License, Version 2.0 (the "License"); you may not use this'
247 " file except in compliance with the License.",
248 ],
249)
250def test_compatible_licenses(license_str: str) -> None:
251 """Test permissive licenses are accepted."""
252 compatible, status = check_license_compatibility(license_str)
253 assert compatible, status
254
255
256def test_license_text_is_read_on_its_grant_only() -> None:
257 """Test a license text is accepted on the grant it spells out, terms added to it aside."""
258 # a grant identifies the license it belongs to, but says nothing about clauses written after
259 # it, so a text adding one is still accepted. Recognising those would mean comparing against
260 # the complete text of every license, which this check does not attempt
261 restricted = (
262 "Permission is hereby granted, free of charge, to any person obtaining a copy of this"
263 ' software and associated documentation files (the "Software"), to deal in the Software'
264 " without restriction.\n\nThe Software shall be used for Good, not Evil."
265 )
266
267 assert check_license_compatibility(restricted)[0]
268
269
270@pytest.mark.parametrize(
271 ("license_str", "expected_status"),
272 [
273 ("GPL-3.0-only", "Incompatible copyleft license (GPL-3.0-only)"),
274 ("AGPL-3.0-only", "Incompatible copyleft license (AGPL-3.0-only)"),
275 # a permissive term must not mask a copyleft one it is combined with, whether or not the
276 # expression around it parses
277 ("MIT AND GPL-3.0-only", "Incompatible copyleft license (MIT AND GPL-3.0-only)"),
278 ("(GPL-3.0-only AND MIT", "Incompatible copyleft license"),
279 ("MIT OR (GPL-3.0-only", "Incompatible copyleft license"),
280 ("LicenseRef-MIT Custom", "Unknown/unverified license"),
281 # only understood in part is not understood: "Zlib" alone would be compatible
282 ("Zlib plus custom terms", "Unknown/unverified license"),
283 ("GNU General Public License v3 (GPLv3)", "Incompatible copyleft license"),
284 # an LGPL term in the string does not excuse a GPL one standing next to it
285 ("LGPL plus GPL terms", "Incompatible copyleft license"),
286 # an exception widens a license, so a copyleft one cannot be hiding behind "WITH"
287 ("MIT WITH GPL-3.0-only", "Incompatible copyleft license"),
288 ("Apache-2.0 AND MIT WITH GPL-3.0-only", "Incompatible copyleft license"),
289 ("LGPL-2.1-or-later AND GPL-3.0-only", "Incompatible copyleft license"),
290 ("Frobnicate-1.0", "Unknown/unverified license (Frobnicate-1.0)"),
291 # a license name has to be named, not spelled out by unrelated words running together
292 ("This copyright notice shall be included in all copies", "Unknown/unverified license"),
293 ("Redistribution is permitted for internal use only", "Unknown/unverified license"),
294 ("SUBMITTED-1.0", "Unknown/unverified license"),
295 ("Mitigation License 1.0", "Unknown/unverified license"),
296 # a name that merely starts like one we know is not that license
297 ("MITX", "Unknown/unverified license"),
298 # prose that says the opposite of the license it names
299 ("This software is not in the public domain. All rights reserved.", "Unknown/unverified"),
300 ("ISC2", "Unknown/unverified license"),
301 ("Internal use only, do not transmit", "Unknown/unverified license"),
302 # a name we accept does not carry the terms written around it, however it is joined to
303 # them, and an SPDX operator between prose words is not an expression to read it out of
304 ("MIT License AND Proprietary", "Unknown/unverified license"),
305 ("MIT License for non-commercial use only", "Unknown/unverified license"),
306 ("MIT plus commercial terms", "Unknown/unverified license"),
307 ("BSD, Proprietary", "Unknown/unverified license"),
308 # a license text is only recognised by the grant it spells out, not by its heading
309 ("MIT License\n\nAll rights reserved. Contact us for terms.", "Unknown/unverified"),
310 # ...and the grant has to be the one the text opens, not the tail of another word
311 (
312 "Nonpermission is hereby granted, free of charge, to any person obtaining a copy of"
313 " this software and associated documentation files.",
314 "Unknown/unverified license",
315 ),
316 # a text that breaks off the grant to restrict it does not spell out that grant
317 (
318 "Permission is hereby granted, free of charge, to any person obtaining a copy solely"
319 " for non-commercial use.",
320 "Unknown/unverified license",
321 ),
322 # ...and neither does one that denies it outright, however the denial is worded
323 (
324 "No permission is hereby granted, free of charge, to any person obtaining a copy of"
325 " this software and associated documentation files.",
326 "Unknown/unverified license",
327 ),
328 (
329 "No additional permission is hereby granted, free of charge, to any person obtaining"
330 " a copy of this software and associated documentation files.",
331 "Unknown/unverified license",
332 ),
333 # a grant is quoted to its last word, so a text trailing off into another license is not
334 # taken for the one it started as
335 (
336 'Licensed under the Apache License, Version 2.0 (the "License"); you may not use this'
337 " file except in compliance with the Proprietary License",
338 "Unknown/unverified license",
339 ),
340 # a copyleft license the text is combined with is not excused by the grant it spells out
341 (
342 "MIT License AND GPL-3.0-only\n\nPermission is hereby granted, free of charge, to any"
343 " person obtaining a copy of this software and associated documentation files.",
344 "Incompatible copyleft license",
345 ),
346 # neither alternative of an expression is one we know, so the expression is not either
347 ("Frobnicate-1.0 OR Frobnicate-2.0", "Unknown/unverified license"),
348 # a value that is only separators names nothing
349 (",", "Unknown/unverified license"),
350 # a license we accept does not carry the one it is offered alongside
351 ("MIT or Proprietary Terms", "Unknown/unverified license"),
352 # a term we cannot read is still a term, in whatever script it is written
353 ("MIT éåç¨", "Unknown/unverified license"),
354 ("Apache 2.0 нелÑзÑ", "Unknown/unverified license"),
355 # a custom license names itself, whatever wording its identifier is built out of
356 (
357 "LicenseRef-Permission-is-hereby-granted-free-of-charge-to-any-person-obtaining-a"
358 "-copy-of-this-software-and-associated-documentation-files",
359 "Unknown/unverified license",
360 ),
361 # groups in prose are not an expression, and no longer a name to read out of it either
362 ("MIT License (a) (b) (c) (d) (e) (f) (g) and so on", "Unknown/unverified license"),
363 # a value that joins licenses is read as an expression, whichever field it came from
364 ("MIT AND Proprietary", "Unknown/unverified license"),
365 ("MIT AND(Proprietary)", "Unknown/unverified license"),
366 ("MIT AND (Proprietary", "Unknown/unverified license"),
367 ("Other/Proprietary License", "Unknown/unverified license"),
368 # a custom license is never pre-approved, not even when its name reads permissive
369 (
370 "LicenseRef-Proprietary-MIT-Terms",
371 "Unknown/unverified license (LicenseRef-Proprietary-MIT-Terms)",
372 ),
373 ("Unknown", "No license information"),
374 ("", "No license information"),
375 # nesting deep enough to exhaust the stack is refused, not approved on the name inside
376 ("(" * 333 + "MIT" + ")" * 333, "Unknown/unverified license"),
377 ],
378)
379def test_incompatible_licenses(license_str: str, expected_status: str) -> None:
380 """Test copyleft and unrecognised licenses are rejected."""
381 compatible, status = check_license_compatibility(license_str)
382 assert not compatible
383 assert status.startswith(expected_status)
384
385
386def test_check_package_reads_license_expression(monkeypatch: pytest.MonkeyPatch) -> None:
387 """Test a package that only declares an SPDX expression passes the license check."""
388 metadata = {
389 "info": {
390 "version": "7.6.0",
391 "license": None,
392 "license_expression": "0BSD",
393 "classifiers": [],
394 "author": "Dan Blanchard",
395 "summary": "Universal encoding detector",
396 "project_urls": {"Homepage": "https://github.com/chardet/chardet"},
397 },
398 "releases": {
399 f"{major}.0.0": [{"upload_time": "2015-01-01T00:00:00"}] for major in range(1, 5)
400 },
401 }
402 monkeypatch.setattr(check_package_safety, "get_pypi_metadata", lambda _: metadata)
403
404 result = check_package("chardet")
405
406 assert result["license"] == "0BSD"
407 assert result["automated_checks"]["license_compatible"]
408 assert result["check_details"]["license"] == "Compatible (0BSD)"
409 assert not [warning for warning in result["warnings"] if "License" in warning]
410