Skip to content

Commit 6778882

Browse files
authored
Merge pull request #128 from arnav-mandal1234/gsoc_similarity_matching
Similarity Matching by Fingerprint Comparison.
2 parents 6e20b14 + 628fea1 commit 6778882

36 files changed

Lines changed: 26224 additions & 4 deletions

src/deltacode/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
# package is not installed ??
4040
__version__ = '1.0.0'
4141

42+
SIMILARITY_LIMIT = 35
4243

4344
class DeltaCode(object):
4445
"""
@@ -60,6 +61,7 @@ def __init__(self, new_path, old_path, options):
6061
self.license_diff()
6162
self.copyright_diff()
6263
self.stats.calculate_stats()
64+
self.similarity()
6365
# Sort deltas by score, descending, i.e., high > low, and then by
6466
# factors, alphabetically. Run the least significant sort first.
6567
self.deltas.sort(key=lambda Delta: Delta.factors, reverse=False)
@@ -80,6 +82,28 @@ def align_scans(self):
8082
for f in self.old.files:
8183
f.original_path = f.path
8284

85+
def similarity(self):
86+
"""
87+
Compare the fingerprints of a pair of 'new' and 'old' File objects
88+
in a Delta object and change the Delta object's 'score' attribute --
89+
and add an appropriate category 'Similar with hamming distance'
90+
to the Delta object's 'factors' attribute -- if the hamming
91+
distance is less than the threshold distance.
92+
"""
93+
for delta in self.deltas:
94+
if delta.new_file == None or delta.old_file == None:
95+
continue
96+
new_fingerprint = delta.new_file.fingerprint
97+
old_fingerprint = delta.old_file.fingerprint
98+
if new_fingerprint == None or old_fingerprint == None:
99+
continue
100+
new_fingerprint = utils.bitarray_from_hex(delta.new_file.fingerprint)
101+
old_fingerprint = utils.bitarray_from_hex(delta.old_file.fingerprint)
102+
hamming_distance = utils.hamming_distance(new_fingerprint, old_fingerprint)
103+
if hamming_distance > 0 and hamming_distance <= SIMILARITY_LIMIT:
104+
delta.score += hamming_distance
105+
delta.factors.append('Similar with hamming distance : {}'.format(hamming_distance))
106+
83107
def determine_delta(self):
84108
"""
85109
Add to a list of Delta objects that can be sorted by their attributes,

src/deltacode/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def __init__(self, dictionary={}):
179179
self.name = dictionary.get('name', '')
180180
self.size = dictionary.get('size', '')
181181
self.sha1 = dictionary.get('sha1', '')
182+
self.fingerprint = dictionary.get('fingerprint', '')
182183
self.original_path = ''
183184
self.licenses = self.get_licenses(dictionary) if dictionary.get('licenses') else []
184185
self.copyrights = self.get_copyrights(dictionary) if dictionary.get('copyrights') else []
@@ -210,6 +211,7 @@ def to_dict(self):
210211
('name', self.name),
211212
('size', self.size),
212213
('sha1', self.sha1),
214+
('fingerprint', self.fingerprint),
213215
('original_path', self.original_path),
214216
])
215217

src/deltacode/test_utils.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# http://nexb.com and https://github.com/nexB/deltacode/
4+
# The DeltaCode software is licensed under the Apache License version 2.0.
5+
# Data generated with DeltaCode require an acknowledgment.
6+
# DeltaCode is a trademark of nexB Inc.
7+
#
8+
# You may not use this software except in compliance with the License.
9+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
10+
# Unless required by applicable law or agreed to in writing, software distributed
11+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
12+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
# When you publish or redistribute any data created with DeltaCode or any DeltaCode
16+
# derivative work, you must accompany this data with the following acknowledgment:
17+
#
18+
# Generated with DeltaCode and provided on an "AS IS" BASIS, WITHOUT WARRANTIES
19+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
20+
# DeltaCode should be considered or used as legal advice. Consult an Attorney
21+
# for any legal advice.
22+
# DeltaCode is a free and open source software analysis tool from nexB Inc. and others.
23+
# Visit https://github.com/nexB/deltacode/ for support and download.
24+
#
25+
26+
from __future__ import absolute_import
27+
from __future__ import print_function
28+
from __future__ import division
29+
from __future__ import unicode_literals
30+
31+
from collections import OrderedDict
32+
33+
import io
34+
import json
35+
36+
from commoncode.system import on_windows
37+
38+
39+
def run_scan_click(options, monkeypatch=None, test_mode=True, expected_rc=0, env=None):
40+
"""
41+
Run a scan as a Click-controlled subprocess
42+
If monkeypatch is provided, a tty with a size (80, 43) is mocked.
43+
Return a click.testing.Result object.
44+
"""
45+
import click
46+
from click.testing import CliRunner
47+
from deltacode import cli
48+
49+
options = add_windows_extra_timeout(options)
50+
51+
if monkeypatch:
52+
monkeypatch.setattr(click._termui_impl, 'isatty', lambda _: True)
53+
monkeypatch.setattr(click , 'get_terminal_size', lambda : (80, 43,))
54+
runner = CliRunner()
55+
56+
result = runner.invoke(cli.cli, options, catch_exceptions=False, env=env)
57+
58+
output = result.output
59+
if result.exit_code != expected_rc:
60+
opts = get_opts(options)
61+
error = '''
62+
Failure to run: deltacode %(opts)s
63+
output:
64+
%(output)s
65+
''' % locals()
66+
assert result.exit_code == expected_rc, error
67+
return result
68+
69+
70+
def get_opts(options):
71+
try:
72+
return ' '.join(options)
73+
except:
74+
try:
75+
return b' '.join(options)
76+
except:
77+
return b' '.join(map(repr, options))
78+
79+
80+
WINDOWS_CI_TIMEOUT = '222.2'
81+
82+
83+
def add_windows_extra_timeout(options, timeout=WINDOWS_CI_TIMEOUT):
84+
"""
85+
Add a timeout to an options list if on Windows.
86+
"""
87+
if on_windows and '--timeout' not in options:
88+
# somehow the Appevyor windows CI is now much slower and timeouts at 120 secs
89+
options += ['--timeout', timeout]
90+
return options
91+
92+
93+
def check_json_scan(expected_file, result_file, regen=False, remove_file_date=False, ignore_headers=False):
94+
"""
95+
Check the scan `result_file` JSON results against the `expected_file`
96+
expected JSON results.
97+
98+
If `regen` is True the expected_file WILL BE overwritten with the new scan
99+
results from `results_file`. This is convenient for updating tests
100+
expectations. But use with caution.
101+
102+
if `remove_file_date` is True, the file.date attribute is removed.
103+
"""
104+
results = load_json_result(result_file, remove_file_date)
105+
if regen:
106+
with open(expected_file, 'wb') as reg:
107+
json.dump(results, reg, indent=2, separators=(',', ': '))
108+
109+
expected = load_json_result(expected_file, remove_file_date)
110+
111+
if ignore_headers:
112+
results.pop('headers', None)
113+
expected.pop('headers', None)
114+
115+
# NOTE we redump the JSON as a string for a more efficient display of the
116+
# failures comparison/diff
117+
# TODO: remove sort, this should no longer be needed
118+
expected = json.dumps(expected, indent=2, sort_keys=True, separators=(',', ': '))
119+
results = json.dumps(results, indent=2, sort_keys=True, separators=(',', ': '))
120+
assert expected == results
121+
122+
123+
def load_json_result(location, remove_file_date=False):
124+
"""
125+
Load the JSON scan results file at `location` location as UTF-8 JSON.
126+
127+
To help with test resilience against small changes some attributes are
128+
removed or streamlined such as the "tool_version" and scan "errors".
129+
130+
To optionally also remove date attributes from "files" and "headers"
131+
entries, set the `remove_file_date` argument to True.
132+
"""
133+
with io.open(location, encoding='utf-8') as res:
134+
scan_results = res.read()
135+
return load_json_result_from_string(scan_results, remove_file_date)
136+
137+
138+
def load_json_result_from_string(string, remove_file_date=False):
139+
"""
140+
Load the JSON scan results `string` as UTF-8 JSON.
141+
"""
142+
scan_results = json.loads(string, object_pairs_hook=OrderedDict)
143+
# clean new headers attributes
144+
streamline_headers(scan_results)
145+
146+
scan_results['deltas'].sort(key=lambda x: x['factors'], reverse=False)
147+
scan_results['deltas'].sort(key=lambda x: x['score'], reverse=True)
148+
return scan_results
149+
150+
151+
def streamline_errors(errors):
152+
"""
153+
Modify the `errors` list in place to make it easier to test
154+
"""
155+
for i, error in enumerate(errors[:]):
156+
error_lines = error.splitlines(True)
157+
if len(error_lines) <= 1:
158+
continue
159+
# keep only first and last line
160+
cleaned_error = ''.join([error_lines[0] + error_lines[-1]])
161+
errors[i] = cleaned_error
162+
163+
164+
def streamline_headers(headers):
165+
"""
166+
Modify the `headers` list of mappings in place to make it easier to test.
167+
"""
168+
headers.pop('deltacode_version', None)
169+
headers.pop('deltacode_options', None)
170+
streamline_errors(headers['deltacode_errors'])

src/deltacode/utils.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@
2525

2626
from __future__ import absolute_import, division
2727

28+
from bitarray import bitarray
2829
from collections import defaultdict
30+
from bitarray import bitdiff
2931

32+
import binascii
3033
import os
3134

32-
3335
from commoncode import paths
3436

35-
3637
def update_from_license_info(delta, unique_categories):
3738
"""
3839
Increase an 'added' or 'modified' Delta object's 'score' attribute and add
@@ -282,3 +283,33 @@ def get_notice():
282283
notice = acknowledgment_text.strip().replace(' ', '')
283284

284285
return notice
286+
287+
288+
def hamming_distance(fingerprint1, fingerprint2):
289+
"""
290+
Return hamming distance between two given fingerprints.
291+
Hamming distance is the difference in the bits of two binary string.
292+
Files with fingerprints whose hamming distance are less tends to be more similar.
293+
"""
294+
distance = bitdiff(fingerprint1, fingerprint2)
295+
result = int(distance)
296+
297+
return result
298+
299+
def bitarray_from_hex(fingerprint_hex):
300+
"""
301+
Return bitarray from a hex string.
302+
"""
303+
bytes = binascii.unhexlify(fingerprint_hex)
304+
result = bitarray_from_bytes(bytes)
305+
306+
return result
307+
308+
def bitarray_from_bytes(b):
309+
"""
310+
Return bitarray from a byte string, interpreted as machine values.
311+
"""
312+
a = bitarray()
313+
a.frombytes(b)
314+
315+
return a

tests/data/cli/scan_1_file_moved_new.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"size": 200,
7272
"sha1": "84b647771481d39dd3a53f6dc210c26abac37748",
7373
"md5": "3cb56efa7140478458dbaa2b30239845",
74+
"fingerprint": "83ee5b9e652faad754b8e20bead792f8",
7475
"files_count": null,
7576
"mime_type": "text/plain",
7677
"file_type": "ASCII text, with CRLF line terminators",
@@ -130,6 +131,7 @@
130131
"size": 200,
131132
"sha1": "310797523e47db8481aeb06f1634317285115091",
132133
"md5": "19efdad483f68bc9997a5c1f7ba41b26",
134+
"fingerprint": "e30cf09443e7878dfcd3288886e97533",
133135
"files_count": null,
134136
"mime_type": "text/plain",
135137
"file_type": "ASCII text, with CRLF line terminators",
@@ -189,6 +191,7 @@
189191
"size": 200,
190192
"sha1": "fd5d3589c825f448546d7dcec36da3e567d35fe9",
191193
"md5": "795ff2cae8ece792a9bfebe18ad3c8e6",
194+
"fingerprint": "e30cf09443e7878dfed3288886e97533",
192195
"files_count": null,
193196
"mime_type": "text/plain",
194197
"file_type": "ASCII text, with CRLF line terminators",
@@ -248,6 +251,7 @@
248251
"size": 200,
249252
"sha1": "6f71666c46446c29d3f45feef5419ae76fb86a5b",
250253
"md5": "fc403815d6605df989414ff40a64ea17",
254+
"fingerprint": "e30cf09443e7878dfed3288886e97542",
251255
"files_count": null,
252256
"mime_type": "text/plain",
253257
"file_type": "ASCII text, with CRLF line terminators",
@@ -307,6 +311,7 @@
307311
"size": 200,
308312
"sha1": "70f6ce80985578b5104db0abc578cf5a05e78f4b",
309313
"md5": "af9e9420c6c5b2b0ca74e96bf7c1a2f4",
314+
"fingerprint": "e30cf09443e7878dfed1088886e97542",
310315
"files_count": null,
311316
"mime_type": "text/plain",
312317
"file_type": "ASCII text, with CRLF line terminators",
@@ -366,6 +371,7 @@
366371
"size": 200,
367372
"sha1": "3340d86b1da9323067db8022f86dc97cfccee1d0",
368373
"md5": "74ce2b26cebb32670634270dde1fdf33",
374+
"fingerprint": "e30cf09443e7878dfed1088886e97232",
369375
"files_count": null,
370376
"mime_type": "text/plain",
371377
"file_type": "ASCII text, with CRLF line terminators",
@@ -425,6 +431,7 @@
425431
"size": 200,
426432
"sha1": "e49d4463662414bee5ad2d2e5c1fbd704f33b84e",
427433
"md5": "8d458f54d32959b03dff37ef485b29c6",
434+
"fingerprint": "e30cf83443e7878dfed1088886e97232",
428435
"files_count": null,
429436
"mime_type": "text/plain",
430437
"file_type": "ASCII text, with CRLF line terminators",
@@ -484,6 +491,7 @@
484491
"size": 200,
485492
"sha1": "98c9e6bed78b1513c28e666016cb35a50708c36e",
486493
"md5": "c3559aef44883a46e007ab01213091cb",
494+
"fingerprint": "e30cf83443e7878dfed1032386e97232",
487495
"files_count": null,
488496
"mime_type": "text/plain",
489497
"file_type": "ASCII text, with CRLF line terminators",

0 commit comments

Comments
 (0)