Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/license_expression/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,100 @@ def dedup(self, expression):
raise ExpressionError(f"Unknown expression type: {expression!r}")
return deduped

def remove(self, expression, licenses_to_remove, **kwargs):
"""
Return a new LicenseExpression with the specified ``licenses_to_remove``
removed from ``expression``, or None if all licenses are removed.

``expression`` is a license expression string or LicenseExpression object.

``licenses_to_remove`` is a license key string, LicenseSymbol,
LicenseExpression object, or an iterable of these.

Composite "WITH" expressions (LicenseWithExceptionSymbol) are treated as
atomic and are removed when matching the exact composite symbol or
expression.

Nested AND/OR expressions are pruned recursively and collapsed when only
a single child remains.

Extra ``kwargs`` are passed down to the parse() function.
"""
if expression is None:
return None

exp = self.parse(expression, **kwargs)
if exp is None:
return None

if licenses_to_remove is None:
return exp

if isinstance(licenses_to_remove, (str, bytes, LicenseExpression, BaseSymbol)):
licenses_to_remove = [licenses_to_remove]
elif isinstance(licenses_to_remove, (list, tuple, set)):
if len(licenses_to_remove) == 0:
return exp
else:
try:
licenses_to_remove = list(licenses_to_remove)
except TypeError:
licenses_to_remove = [licenses_to_remove]

targets = []
for target in licenses_to_remove:
if target is None:
continue
if isinstance(target, (LicenseExpression, BaseSymbol)):
targets.append(target)
else:
parsed_target = self.parse(target, **kwargs)
if parsed_target is not None:
targets.append(parsed_target)

if not targets:
return exp

def _remove(node):
if node is None:
return None

for t in targets:
if node == t:
return None

if isinstance(node, BaseSymbol):
return node

if isinstance(node, (self.AND, self.OR)):
relation = node.__class__.__name__
filtered_args = []
for arg in node.args:
res = _remove(arg)
if res is not None:
filtered_args.append(res)

if relation == "AND":
flattened = []
for e in filtered_args:
if isinstance(e, self.AND):
flattened.extend(e.args)
else:
flattened.append(e)
filtered_args = flattened

unique_args = ordered_unique(filtered_args)

if not unique_args:
return None
if len(unique_args) == 1:
return unique_args[0]
return node.__class__(*unique_args)

raise ExpressionError(f"Unknown expression type: {node!r}")

return _remove(exp)

def validate(self, expression, strict=True, **kwargs):
"""
Return a ExpressionInfo object that contains information about
Expand Down
137 changes: 137 additions & 0 deletions tests/test_license_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -2640,3 +2640,140 @@ def test_combine_expressions_with_duplicated_elements(self):

def test_combine_expressions_with_or_relationship(self):
assert str(combine_expressions(["mit", "apache-2.0"], "OR")) == "mit OR apache-2.0"


class LicensingRemoveTest(TestCase):
def setUp(self):
self.licensing = Licensing()

def test_remove_from_and_expression(self):
result = self.licensing.remove("MIT AND Apache-2.0", "Apache-2.0")
assert result.render() == "MIT"

def test_remove_from_or_expression(self):
result = self.licensing.remove("MIT OR Apache-2.0", "Apache-2.0")
assert result.render() == "MIT"

def test_remove_first_term_from_and(self):
result = self.licensing.remove("MIT AND Apache-2.0", "MIT")
assert result.render() == "Apache-2.0"

def test_remove_first_term_from_or(self):
result = self.licensing.remove("MIT OR Apache-2.0", "MIT")
assert result.render() == "Apache-2.0"

def test_remove_nested_and_or_expressions(self):
expr = "(MIT AND Apache-2.0) OR GPL-2.0"
result = self.licensing.remove(expr, "Apache-2.0")
assert result.render() == "MIT OR GPL-2.0"

result = self.licensing.remove(expr, "GPL-2.0")
assert result.render() == "MIT AND Apache-2.0"

def test_remove_complete_subexpression_string(self):
expr = "(MIT AND Apache-2.0) OR GPL-2.0"
result = self.licensing.remove(expr, "MIT AND Apache-2.0")
assert result.render() == "GPL-2.0"

def test_remove_complete_subexpression_object(self):
expr = self.licensing.parse("(MIT AND Apache-2.0) OR GPL-2.0")
subexpr = self.licensing.parse("MIT AND Apache-2.0")
result = self.licensing.remove(expr, subexpr)
assert result.render() == "GPL-2.0"

def test_remove_nonexistent_license(self):
expr = "MIT AND Apache-2.0"
result = self.licensing.remove(expr, "GPL-2.0")
assert result.render() == "MIT AND Apache-2.0"

def test_remove_all_terms_single_license(self):
result = self.licensing.remove("MIT", "MIT")
assert result is None

def test_remove_all_terms_from_and(self):
result = self.licensing.remove("MIT AND Apache-2.0", ["MIT", "Apache-2.0"])
assert result is None

def test_remove_all_terms_from_or(self):
result = self.licensing.remove("MIT OR Apache-2.0", ["MIT", "Apache-2.0"])
assert result is None

def test_remove_all_terms_from_nested(self):
expr = "(MIT AND Apache-2.0) OR GPL-2.0"
result = self.licensing.remove(expr, ["MIT", "Apache-2.0", "GPL-2.0"])
assert result is None

def test_remove_with_duplicate_terms(self):
expr = "MIT AND MIT AND Apache-2.0"
result = self.licensing.remove(expr, "Apache-2.0")
assert result.render() == "MIT"

def test_remove_with_parenthesized_complex_expressions(self):
expr = "(MIT OR BSD) AND (Apache-2.0 OR GPL-2.0)"
result = self.licensing.remove(expr, "BSD")
assert result.render() == "MIT AND (Apache-2.0 OR GPL-2.0)"

result = self.licensing.remove(expr, ["BSD", "GPL-2.0"])
assert result.render() == "MIT AND Apache-2.0"

def test_remove_with_expression_exact_composite_string(self):
expr = "GPL-2.0 WITH Classpath-exception OR MIT"
result = self.licensing.remove(expr, "GPL-2.0 WITH Classpath-exception")
assert result.render() == "MIT"

def test_remove_with_expression_exact_composite_object(self):
lic_sym = LicenseSymbol("GPL-2.0")
exc_sym = LicenseSymbol("Classpath-exception")
with_sym = LicenseWithExceptionSymbol(lic_sym, exc_sym)
expr = "GPL-2.0 WITH Classpath-exception OR MIT"
result = self.licensing.remove(expr, with_sym)
assert result.render() == "MIT"

def test_remove_with_expression_exact_composite_object_with_known_symbols(self):
gpl = LicenseSymbol("GPL-2.0")
exc = LicenseSymbol("Classpath-exception", is_exception=True)
mit = LicenseSymbol("MIT")
licensing = Licensing([gpl, exc, mit])
with_sym = LicenseWithExceptionSymbol(gpl, exc)
expr = "GPL-2.0 WITH Classpath-exception OR MIT"
result = licensing.remove(expr, with_sym)
assert result.render() == "MIT"

def test_remove_with_expression_all_removed(self):
expr = "GPL-2.0 WITH Classpath-exception"
result = self.licensing.remove(expr, "GPL-2.0 WITH Classpath-exception")
assert result is None

def test_remove_base_license_does_not_affect_composite_with_expression(self):
expr = "GPL-2.0 WITH Classpath-exception OR GPL-2.0"
result = self.licensing.remove(expr, "GPL-2.0")
assert result.render() == "GPL-2.0 WITH Classpath-exception"

def test_remove_with_symbol_object_target(self):
expr = "MIT AND Apache-2.0"
result = self.licensing.remove(expr, LicenseSymbol("Apache-2.0"))
assert result.render() == "MIT"

def test_remove_with_parsed_expression_input(self):
expr = self.licensing.parse("MIT AND Apache-2.0")
result = self.licensing.remove(expr, "Apache-2.0")
assert result.render() == "MIT"

def test_remove_empty_or_none_expression(self):
assert self.licensing.remove(None, "MIT") is None
assert self.licensing.remove("", "MIT") is None
assert self.licensing.remove(" ", "MIT") is None

def test_remove_empty_or_none_targets(self):
expr = "MIT AND Apache-2.0"
assert self.licensing.remove(expr, None).render() == "MIT AND Apache-2.0"
assert self.licensing.remove(expr, []).render() == "MIT AND Apache-2.0"
assert self.licensing.remove(expr, "").render() == "MIT AND Apache-2.0"

def test_remove_with_known_symbols_and_aliases(self):
gpl2 = LicenseSymbol("GPL-2.0", aliases=["gpl v2", "gpl2"])
mit = LicenseSymbol("MIT", aliases=["mit license"])
licensing = Licensing([gpl2, mit])
expr = "gpl v2 OR mit license"
result = licensing.remove(expr, "gpl2")
assert result.render() == "MIT"