Skip to content

Commit fd628e6

Browse files
committed
Add new draft LicenseDetection
This support new ways to combine multiple license matches together, and distinguish primary and secondary licenses. Most concepts have been drawn from early work on Debian copyright detection improvements. Signed-off-by: Philippe Ombredanne <pombredanne@nexb.com>
1 parent ad4dfff commit fd628e6

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

src/licensedcode/detection.py

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# ScanCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/nexB/scancode-toolkit for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
from enum import Enum
11+
12+
import attr
13+
from license_expression import combine_expressions
14+
15+
from licensedcode.cache import build_spdx_license_expression
16+
from licensedcode.cache import get_cache
17+
from licensedcode.match import LicenseMatch
18+
from licensedcode.models import compute_relevance
19+
20+
"""
21+
LicenseDetection data structure and processing.
22+
23+
A LicenseDetection combines one or more matches together using various rules and
24+
heuristics.
25+
"""
26+
27+
TRACE = False
28+
29+
30+
def logger_debug(*args): pass
31+
32+
33+
if TRACE:
34+
use_print = True
35+
if use_print:
36+
prn = print
37+
else:
38+
import logging
39+
import sys
40+
logger = logging.getLogger(__name__)
41+
# logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
42+
logging.basicConfig(stream=sys.stdout)
43+
logger.setLevel(logging.DEBUG)
44+
prn = logger.debug
45+
46+
def logger_debug(*args):
47+
return prn(' '.join(isinstance(a, str) and a or repr(a) for a in args))
48+
49+
50+
def matches_compact_repr(matches):
51+
"""
52+
Return a string representing a list of license matches in a compact way.
53+
"""
54+
return ', '.join(m.rule.identifier for m in matches)
55+
56+
57+
@attr.s(slots=True, eq=False, order=False)
58+
class LicenseDetection:
59+
"""
60+
A LicenseDetection combines one or more LicenseMatch using multiple rules
61+
and heuristics. For instance, a "license intro" match followed by a proper
62+
match may be combined in a single detection for the matched license
63+
expression.
64+
"""
65+
66+
matches = attr.ib(
67+
repr=matches_compact_repr,
68+
default=attr.Factory(list),
69+
metadata=dict(
70+
help='List of license matches combined in this detection.'
71+
)
72+
)
73+
74+
length = attr.ib(
75+
default=0,
76+
metadata=dict(help=
77+
'Detection length as the number of known tokens across all matches. '
78+
'Because of the possible overlap this may be inaccurate.'
79+
)
80+
)
81+
82+
primary_license_expression = attr.ib(
83+
default=None,
84+
metadata=dict(
85+
help='Primary license expression string '
86+
'using the SPDX license expression syntax and ScanCode license keys.')
87+
)
88+
89+
license_expression = attr.ib(
90+
default=None,
91+
metadata=dict(
92+
help='Full license expression string '
93+
'using the SPDX license expression syntax and ScanCode license keys.')
94+
)
95+
96+
primary_spdx_license_expression = attr.ib(
97+
repr=False,
98+
default=None,
99+
metadata=dict(
100+
help='License expression string for this license detection'
101+
'using the SPDX license expression syntax and SPDX license ids.')
102+
)
103+
104+
spdx_license_expression = attr.ib(
105+
repr=False,
106+
default=None,
107+
metadata=dict(
108+
help='Full license expression string for this license detection'
109+
'using the SPDX license expression syntax and SPDX license ids.')
110+
)
111+
112+
combination_reasons = attr.ib(
113+
repr=False,
114+
default=attr.Factory(list),
115+
metadata=dict(
116+
help='A list of detection CombinationReason explaining how '
117+
'this detection was created.'
118+
)
119+
)
120+
121+
def __eq__(self, other):
122+
return (
123+
isinstance(other, LicenseDetection)
124+
and self.matches == other.matches
125+
)
126+
127+
def rules_length(self):
128+
"""
129+
Return the length of the combined matched rules as the number
130+
of all rule tokens.
131+
Because of the possible overlap this may be inaccurate.
132+
"""
133+
return sum(m.self.rule.length for m in self.matches)
134+
135+
def coverage(self):
136+
"""
137+
Return the score for this detection as a rounded float between 0 and 100.
138+
139+
This is an indication of the how much this detection covers the rules of
140+
the underlying match.
141+
142+
This is computed as the sum of the underlying matches coverage weighted
143+
by the length of a match to the overall detection length.
144+
"""
145+
length = self.length
146+
weighted_coverages = (m.coverage() * (m.len() / length) for m in self.matches)
147+
return min([round(sum(weighted_coverages), 2), 100])
148+
149+
def relevance(self):
150+
"""
151+
Return the ``relevance`` of this detection. The relevance
152+
is a float between 0 and 100 where 100 means highly relevant and 0 means
153+
not relevant at all.
154+
155+
This is computed as the relevance of the sum of the underlying matches
156+
rule length.
157+
"""
158+
return compute_relevance(self.rules_len)
159+
160+
def score(self):
161+
"""
162+
Return the score for this detection as a rounded float between 0 and 100.
163+
164+
The score is an indication of the confidence of the detection.
165+
166+
This is computed as the sum of the underlying matches score weighted
167+
by the length of a match to the overall detection length.
168+
"""
169+
length = self.length
170+
weighted_scores = (m.score() * (m.len() / length) for m in self.matches)
171+
return min([round(sum(weighted_scores), 2), 100])
172+
173+
def append(
174+
self,
175+
match,
176+
reason=None,
177+
combine_license=False,
178+
override_license=False,
179+
):
180+
"""
181+
Append the ``match`` LicenseMatch to this detection and update it
182+
accordingly.
183+
Append the ``reason`` to the combination_reasons.
184+
185+
If ``combine_license`` is True the license_expression of the ``match``
186+
is combined with the detection license_expression. Do not combine
187+
otherwise.
188+
189+
If ``override_license`` is True, the license_expression of the ``match``
190+
replaces the the detection license_expression. Do not override license
191+
otherwise.
192+
193+
``combine_license`` and ``override_license`` are ignored for the first
194+
match appended to this detection: license is taken as is in this case.
195+
"""
196+
if not isinstance(match, LicenseMatch):
197+
raise TypeError(f'Not a LicenseMatch: {match!r}')
198+
assert not (combine_license and override_license), (
199+
'combine_license and override_license are mutually exclusive'
200+
)
201+
202+
if not self.matches:
203+
# first match is always an ovveride
204+
combine_license = False
205+
override_license = True
206+
207+
self.matches.append(match)
208+
self.length += match.length
209+
if reason:
210+
self.combination_reasons.append(reason)
211+
212+
licensing = get_cache().licensing
213+
if combine_license:
214+
license_expression = combine_expressions(
215+
[self.license_expression, match.license_expression],
216+
unique=True,
217+
licensing=licensing,
218+
)
219+
220+
self.spdx_license_expression = build_spdx_license_expression(
221+
license_expression,
222+
licensing=licensing,
223+
)
224+
# FIXME: we are not yet doing anything wrt. primary licenses
225+
self.primary_license_expression = str(license_expression)
226+
self.primary_spdx_license_expression = self.spdx_license_expression
227+
228+
self.license_expression = str(license_expression)
229+
230+
elif override_license:
231+
# Use the match expression
232+
license_expression = licensing.parse(match.license_expression)
233+
234+
self.spdx_license_expression = build_spdx_license_expression(
235+
license_expression,
236+
licensing=licensing,
237+
)
238+
# FIXME: we are not yet doing anything wrt. primary licenses
239+
self.primary_license_expression = str(license_expression)
240+
self.primary_spdx_license_expression = self.spdx_license_expression
241+
242+
self.license_expression = str(license_expression)
243+
244+
def matched_text(
245+
self,
246+
whole_lines=False,
247+
highlight=True,
248+
highlight_matched=u'%s',
249+
highlight_not_matched=u'[%s]',
250+
):
251+
"""
252+
Return the matched text for this detection, combining texts from all
253+
matches (that can possibly for different files.)
254+
"""
255+
return '\n'.join(
256+
m.matched_text(
257+
whole_lines=whole_lines,
258+
highlight=highlight,
259+
highlight_matched=highlight_matched,
260+
highlight_not_matched=highlight_not_matched,
261+
_usecache=True
262+
)
263+
for m in self.matches
264+
)
265+
266+
267+
def combine_license_intros(license_matches):
268+
"""
269+
Return a filtered ``license_matches`` list of LicenseMatch objects removing
270+
spurious matches to license introduction statements (e.g.
271+
`is_license_intro` Rules.)
272+
273+
A common source of false positive license detections in unstructured files
274+
are license introduction statements that are immediately followed by a
275+
license notice. In these cases, the license introduction can be discarded as
276+
this is for the license match that follows it.
277+
"""
278+
279+
return [match for match in license_matches if not is_license_intro(match)]
280+
281+
282+
def is_license_intro(license_match):
283+
"""
284+
Return True if `license_match` LicenseMatch object is matched completely to
285+
a unknown license intro present as a Rule.
286+
"""
287+
from licensedcode.match_aho import MATCH_AHO_EXACT
288+
289+
return (
290+
license_match.rule.is_license_intro
291+
and (
292+
license_match.matcher == MATCH_AHO_EXACT
293+
or license_match.coverage() == 100
294+
)
295+
)
296+
297+
298+
class CombinationReason(Enum):
299+
NOT_COMBINED = 'not-combined'
300+
UNKNOWN_INTRO_FOLLOWED_BY_MATCH = 'unknown-intro-followed-by-match'
301+
CONTAINED_SAME_LICENSE = 'contained-with-same-license'
302+
NOTICE_FOLLOWED_BY_TEXT = 'notice-followed-by-text'
303+
CONTIGUOUS_SAME_LICENSE = 'contiguous-with-same-license'
304+
REF_FOLLOWED_BY_NOTICE = 'ref-followed-by-notice'
305+
REF_FOLLOWED_BY_TEXT = 'ref-followed-by-text'
306+
TAG_FOLLOWED_BY_NOTICE = 'tag-followed-by-notice'
307+
TAG_FOLLOWED_BY_TEXT = 'tag-followed-by-text'
308+
TAG_FOLLOWED_BY_REF = 'tag-followed-by-ref'
309+
UNKNOWN_FOLLOWED_BY_MATCH = 'unknown-ref-followed-by-match'
310+
UNVERSIONED_FOLLOWED_BY_VERSIONED = 'un-versioned-followed-by-versioned'
311+
312+
313+
def combine_matches_in_detections(matches):
314+
"""
315+
Return a list of LicenseDetection given a ``matches`` list of LicenseMatch.
316+
"""
317+
# do not bother if there is only one match
318+
if len(matches) < 2:
319+
ld = LicenseDetection()
320+
ld.append(matches[0], CombinationReason.NOT_COMBINED)
321+
return [ld]
322+
323+
matches = sorted(matches)
324+
325+
detections = []
326+
current_detection = None
327+
328+
# Compare current and next matches in the sorted sequence
329+
# Compare a pair and combine in LicenseDetection when relevant
330+
331+
i = 0
332+
while i < len(matches) - 1:
333+
j = i + 1
334+
while j < len(matches):
335+
current_match = matches[i]
336+
next_match = matches[j]
337+
338+
if not current_detection:
339+
current_detection = LicenseDetection()
340+
current_detection.append(current_match)
341+
342+
# BREAK/shortcircuit rather than continue since continuing looking
343+
# next matches will yield no new possible addition to this
344+
# detection. e.g. stop when the distance between matches is too
345+
# large
346+
if current_match.distance_to(next_match) > 10:
347+
detections.append(current_detection)
348+
current_detection = None
349+
break
350+
351+
# UNKNOWN_INTRO_FOLLOWED_BY_MATCH: combine current and next
352+
if (
353+
current_match.rule.is_license_intro and
354+
current_match.rule.is_unknown and (
355+
next_match.rule.is_license_reference
356+
or next_match.rule.is_license_text
357+
or next_match.rule.is_license_notice
358+
)
359+
):
360+
current_detection.append(
361+
match=next_match,
362+
reason=CombinationReason.UNKNOWN_INTRO_FOLLOWED_BY_MATCH,
363+
override_license=True,
364+
)
365+
366+
# CONTAINED_SAME_LICENSE: combine current and next
367+
elif (
368+
current_match.same_licensing(next_match) and
369+
current_match.qcontains(next_match)
370+
):
371+
current_detection.append(
372+
match=next_match,
373+
reason=CombinationReason.CONTAINED_SAME_LICENSE,
374+
# no license changes
375+
override_license=False,
376+
combine_license=False,
377+
)
378+
379+
else:
380+
# do not combine, start a new detection
381+
detections.append(current_detection)
382+
current_detection = None
383+
384+
j += 1
385+
i += 1
386+
387+
return detections

0 commit comments

Comments
 (0)