From ece2552c16827d1b70b63e7d797b2e8412f86e36 Mon Sep 17 00:00:00 2001 From: bhavesh200628-debug <243262296+bhavesh200628-debug@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:21:47 +0530 Subject: [PATCH] Add license expression removal Signed-off-by: bhavesh200628-debug <243262296+bhavesh200628-debug@users.noreply.github.com> --- src/license_expression/__init__.py | 94 ++++++++++++++++++++ tests/test_license_expression.py | 137 +++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) diff --git a/src/license_expression/__init__.py b/src/license_expression/__init__.py index 13170b4..c2534d6 100644 --- a/src/license_expression/__init__.py +++ b/src/license_expression/__init__.py @@ -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 diff --git a/tests/test_license_expression.py b/tests/test_license_expression.py index 7af4175..a36cadc 100644 --- a/tests/test_license_expression.py +++ b/tests/test_license_expression.py @@ -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"