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