Merge lp:~ben-hutchings/ensoft-sextant/filter-search into lp:ensoft-sextant
- filter-search
- Merge into whiteline
Status: | Superseded |
---|---|
Proposed branch: | lp:~ben-hutchings/ensoft-sextant/filter-search |
Merge into: | lp:ensoft-sextant |
Prerequisite: | lp:~ben-hutchings/ensoft-sextant/autocomplete-fix |
Diff against target: |
696 lines (+239/-112) 8 files modified
resources/sextant/web/interface.html (+2/-2) src/sextant/__main__.py (+8/-5) src/sextant/db_api.py (+118/-59) src/sextant/export.py (+1/-1) src/sextant/objdump_parser.py (+82/-33) src/sextant/test_parser.py (+1/-1) src/sextant/update_db.py (+15/-8) src/sextant/web/server.py (+12/-3) |
To merge this branch: | bzr merge lp:~ben-hutchings/ensoft-sextant/filter-search |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Robert | Pending | ||
Review via email: mp+242079@code.launchpad.net |
This proposal supersedes a proposal from 2014-11-17.
This proposal has been superseded by a proposal from 2014-11-19.
Commit message
Description of the change
Function name search within the web frontend now supports extended syntax:
'<name matches>:<file path matches>'
where name matches and file path matches are (possibly) comma separated lists, and may include wildcards '.*'. At least one of the two must be specified.
Fixed bug with inline functions being uploaded multiple times into the database.
Fixed bug with over-zealous name stripping of function identifiers.
Fixed bug by which some functions were not uploaded.
- 45. By Ben Hutchings
-
markup fixes
- 46. By Ben Hutchings
-
markups + small bug fixes - tests do not pass (though the functionality works).
- 47. By Ben Hutchings
-
tuple instead of list
- 48. By Ben Hutchings
-
merge from autocomplete-fix
- 49. By Ben Hutchings
-
another merge from autocomplete-fix
- 50. By Ben Hutchings
-
fixed bug causing extrac characters to be removed from the start of symbol names
Unmerged revisions
Preview Diff
1 | === modified file 'resources/sextant/web/interface.html' | |||
2 | --- resources/sextant/web/interface.html 2014-11-19 10:32:48 +0000 | |||
3 | +++ resources/sextant/web/interface.html 2014-11-19 10:32:48 +0000 | |||
4 | @@ -27,8 +27,8 @@ | |||
5 | 27 | All functions calling specific function</option> | 27 | All functions calling specific function</option> |
6 | 28 | <option value="functions_called_by"> | 28 | <option value="functions_called_by"> |
7 | 29 | All functions called by a specific function</option> | 29 | All functions called by a specific function</option> |
10 | 30 | <option value="all_call_paths"> | 30 | <!--option value="all_call_paths"> REMOVED AS THIS IS SLOW FOR IOS |
11 | 31 | All function call paths between two functions</option> | 31 | All function call paths between two functions</option--> |
12 | 32 | <option value="shortest_call_path"> | 32 | <option value="shortest_call_path"> |
13 | 33 | Shortest path between two functions</option> | 33 | Shortest path between two functions</option> |
14 | 34 | <option value="function_names"> | 34 | <option value="function_names"> |
15 | 35 | 35 | ||
16 | === modified file 'src/sextant/__main__.py' | |||
17 | --- src/sextant/__main__.py 2014-10-17 15:30:14 +0000 | |||
18 | +++ src/sextant/__main__.py 2014-11-19 10:32:48 +0000 | |||
19 | @@ -127,16 +127,13 @@ | |||
20 | 127 | except TypeError: | 127 | except TypeError: |
21 | 128 | alternative_name = None | 128 | alternative_name = None |
22 | 129 | 129 | ||
23 | 130 | not_object_file = args.not_object_file | ||
24 | 131 | # the default is "yes, this is an object file" if not-object-file was | ||
25 | 132 | # unsupplied | ||
26 | 133 | |||
27 | 134 | try: | 130 | try: |
28 | 135 | update_db.upload_program(connection, | 131 | update_db.upload_program(connection, |
29 | 136 | getpass.getuser(), | 132 | getpass.getuser(), |
30 | 137 | args.input_file, | 133 | args.input_file, |
31 | 138 | alternative_name, | 134 | alternative_name, |
33 | 139 | not_object_file) | 135 | args.not_object_file, |
34 | 136 | args.add_file_paths) | ||
35 | 140 | except requests.exceptions.ConnectionError as e: | 137 | except requests.exceptions.ConnectionError as e: |
36 | 141 | msg = 'Connection error to server {}: {}' | 138 | msg = 'Connection error to server {}: {}' |
37 | 142 | logging.error(msg.format(_displayable_url(args), e)) | 139 | logging.error(msg.format(_displayable_url(args), e)) |
38 | @@ -221,6 +218,12 @@ | |||
39 | 221 | help='default False, if the input file is an ' | 218 | help='default False, if the input file is an ' |
40 | 222 | 'object to be disassembled', | 219 | 'object to be disassembled', |
41 | 223 | action='store_true') | 220 | action='store_true') |
42 | 221 | parsers['add'].add_argument('--add-file-paths', | ||
43 | 222 | help='default False, set to True to make objdump ' | ||
44 | 223 | 'extract the file paths for each function. ' | ||
45 | 224 | 'WARNING: this is SLOW for large object files, ' | ||
46 | 225 | '~15 hours for IOS.', | ||
47 | 226 | action='store_true') | ||
48 | 224 | 227 | ||
49 | 225 | parsers['delete'] = subparsers.add_parser('delete-program', | 228 | parsers['delete'] = subparsers.add_parser('delete-program', |
50 | 226 | help="delete a program from the database") | 229 | help="delete a program from the database") |
51 | 227 | 230 | ||
52 | === modified file 'src/sextant/db_api.py' | |||
53 | --- src/sextant/db_api.py 2014-11-19 10:32:48 +0000 | |||
54 | +++ src/sextant/db_api.py 2014-11-19 10:32:48 +0000 | |||
55 | @@ -159,7 +159,7 @@ | |||
56 | 159 | tmp_path = os.path.join(self._tmp_dir, '{}_{{}}'.format(program_name)) | 159 | tmp_path = os.path.join(self._tmp_dir, '{}_{{}}'.format(program_name)) |
57 | 160 | 160 | ||
58 | 161 | self.func_writer = CSVWriter(tmp_path.format('funcs'), | 161 | self.func_writer = CSVWriter(tmp_path.format('funcs'), |
60 | 162 | headers=['name', 'type'], | 162 | headers=['name', 'type', 'file'], |
61 | 163 | max_rows=5000) | 163 | max_rows=5000) |
62 | 164 | self.call_writer = CSVWriter(tmp_path.format('calls'), | 164 | self.call_writer = CSVWriter(tmp_path.format('calls'), |
63 | 165 | headers=['caller', 'callee'], | 165 | headers=['caller', 'callee'], |
64 | @@ -171,7 +171,7 @@ | |||
65 | 171 | ' WITH line, toInt(line.id) as lineid' | 171 | ' WITH line, toInt(line.id) as lineid' |
66 | 172 | ' MATCH (n:program {{name: "{}"}})' | 172 | ' MATCH (n:program {{name: "{}"}})' |
67 | 173 | ' CREATE (n)-[:subject]->(m:func {{name: line.name,' | 173 | ' CREATE (n)-[:subject]->(m:func {{name: line.name,' |
69 | 174 | ' id: lineid, type: line.type}})') | 174 | ' id: lineid, type: line.type, file: line.file}})') |
70 | 175 | 175 | ||
71 | 176 | self.add_call_query = (' USING PERIODIC COMMIT 250' | 176 | self.add_call_query = (' USING PERIODIC COMMIT 250' |
72 | 177 | ' LOAD CSV WITH HEADERS FROM "file:{}" AS line' | 177 | ' LOAD CSV WITH HEADERS FROM "file:{}" AS line' |
73 | @@ -203,7 +203,7 @@ | |||
74 | 203 | # Propagate the error if there is one. | 203 | # Propagate the error if there is one. |
75 | 204 | return False if etype is not None else True | 204 | return False if etype is not None else True |
76 | 205 | 205 | ||
78 | 206 | def add_function(self, name, typ='normal'): | 206 | def add_function(self, name, typ='normal', source='unknown'): |
79 | 207 | """ | 207 | """ |
80 | 208 | Add a function. | 208 | Add a function. |
81 | 209 | 209 | ||
82 | @@ -219,7 +219,7 @@ | |||
83 | 219 | pointer: we know only that the function exists, not its | 219 | pointer: we know only that the function exists, not its |
84 | 220 | name or details. | 220 | name or details. |
85 | 221 | """ | 221 | """ |
87 | 222 | self.func_writer.write(name, typ) | 222 | self.func_writer.write(name, typ, source) |
88 | 223 | 223 | ||
89 | 224 | def add_call(self, caller, callee): | 224 | def add_call(self, caller, callee): |
90 | 225 | """ | 225 | """ |
91 | @@ -257,6 +257,19 @@ | |||
92 | 257 | remote_paths: | 257 | remote_paths: |
93 | 258 | A list of the paths of the remote fils. | 258 | A list of the paths of the remote fils. |
94 | 259 | """ | 259 | """ |
95 | 260 | |||
96 | 261 | def try_rmdir(path): | ||
97 | 262 | # Helper function to try and remove a directory, silently | ||
98 | 263 | # fail if it contains files, otherwise raise the exception. | ||
99 | 264 | try: | ||
100 | 265 | os.rmdir(path) | ||
101 | 266 | except OSError as e: | ||
102 | 267 | if e.errno in [os.errno.ENOTEMPTY, os.errno.ENOENT]: | ||
103 | 268 | # Files in directory or directory doesn't exist. | ||
104 | 269 | pass | ||
105 | 270 | else: | ||
106 | 271 | raise e | ||
107 | 272 | |||
108 | 260 | print('Cleaning temporary files...', end='') | 273 | print('Cleaning temporary files...', end='') |
109 | 261 | file_paths = list(itertools.chain(self.func_writer.file_iter(), | 274 | file_paths = list(itertools.chain(self.func_writer.file_iter(), |
110 | 262 | self.call_writer.file_iter())) | 275 | self.call_writer.file_iter())) |
111 | @@ -264,16 +277,9 @@ | |||
112 | 264 | for path in file_paths: | 277 | for path in file_paths: |
113 | 265 | os.remove(path) | 278 | os.remove(path) |
114 | 266 | 279 | ||
125 | 267 | os.rmdir(self._tmp_dir) | 280 | try_rmdir(self._tmp_dir) |
126 | 268 | 281 | try_rmdir(TMP_DIR) | |
127 | 269 | try: | 282 | |
118 | 270 | # If the parent sextant temp folder is empty, remove it. | ||
119 | 271 | os.rmdir(TMP_DIR) | ||
120 | 272 | except: | ||
121 | 273 | # There is other stuff in TMP_DIR (i.e. from other users), so | ||
122 | 274 | # leave it. | ||
123 | 275 | pass | ||
124 | 276 | |||
128 | 277 | self._ssh.remove_from_tmp_dir(remote_paths) | 283 | self._ssh.remove_from_tmp_dir(remote_paths) |
129 | 278 | 284 | ||
130 | 279 | print('done.') | 285 | print('done.') |
131 | @@ -290,6 +296,7 @@ | |||
132 | 290 | 296 | ||
133 | 291 | tx.append('CREATE CONSTRAINT ON (p:program) ASSERT p.name IS UNIQUE') | 297 | tx.append('CREATE CONSTRAINT ON (p:program) ASSERT p.name IS UNIQUE') |
134 | 292 | tx.append('CREATE INDEX ON :func(name)') | 298 | tx.append('CREATE INDEX ON :func(name)') |
135 | 299 | tx.append('CREATE INDEX ON: func(file)') | ||
136 | 293 | 300 | ||
137 | 294 | # Apply the transaction. | 301 | # Apply the transaction. |
138 | 295 | tx.commit() | 302 | tx.commit() |
139 | @@ -832,7 +839,7 @@ | |||
140 | 832 | result = self._db.query(q, returns=neo4jrestclient.Node) | 839 | result = self._db.query(q, returns=neo4jrestclient.Node) |
141 | 833 | return bool(result) | 840 | return bool(result) |
142 | 834 | 841 | ||
144 | 835 | def get_function_names(self, program_name, search, max_funcs): | 842 | def get_function_names(self, program_name, search=None, max_funcs=None): |
145 | 836 | """ | 843 | """ |
146 | 837 | Execute query to retrieve a list of all functions in the program. | 844 | Execute query to retrieve a list of all functions in the program. |
147 | 838 | Any of the output names can be used verbatim in any SextantConnection | 845 | Any of the output names can be used verbatim in any SextantConnection |
148 | @@ -845,15 +852,82 @@ | |||
149 | 845 | if not validate_query(program_name): | 852 | if not validate_query(program_name): |
150 | 846 | return set() | 853 | return set() |
151 | 847 | 854 | ||
152 | 855 | limit = "LIMIT {}".format(max_funcs) if max_funcs else "" | ||
153 | 856 | |||
154 | 848 | if not search: | 857 | if not search: |
155 | 849 | q = (' MATCH (:program {{name: "{}"}})-[:subject]->(f:func)' | 858 | q = (' MATCH (:program {{name: "{}"}})-[:subject]->(f:func)' |
157 | 850 | ' RETURN f.name LIMIT {}').format(program_name, max_funcs) | 859 | ' RETURN f.name {}').format(program_name, limit) |
158 | 851 | else: | 860 | else: |
159 | 852 | q = (' MATCH (:program {{name: "{}"}})-[:subject]->(f:func)' | 861 | q = (' MATCH (:program {{name: "{}"}})-[:subject]->(f:func)' |
162 | 853 | ' WHERE f.name =~ ".*{}.*" RETURN f.name LIMIT {}' | 862 | ' WHERE f.name =~ ".*{}.*" RETURN f.name {}' |
163 | 854 | .format(program_name, search, max_funcs)) | 863 | .format(program_name, search, limit)) |
164 | 855 | return {func[0] for func in self._db.query(q)} | 864 | return {func[0] for func in self._db.query(q)} |
165 | 856 | 865 | ||
166 | 866 | @staticmethod | ||
167 | 867 | def get_query(identifier, search): | ||
168 | 868 | """ | ||
169 | 869 | Builds a filter query from a search pattern which may contain commas | ||
170 | 870 | and/or wildcards. | ||
171 | 871 | |||
172 | 872 | Return: | ||
173 | 873 | string: part of a valid cypher query. | ||
174 | 874 | Arguments: | ||
175 | 875 | identifier: | ||
176 | 876 | The identifier of the node whose properties to filter on, | ||
177 | 877 | e.g. 'f' after a 'MATCH (f:func) ...' | ||
178 | 878 | search: | ||
179 | 879 | The pattern to build the search from, of form: | ||
180 | 880 | '<name patterns>:<path patterns>' | ||
181 | 881 | where patterns are possibly empty, possibly comma separated | ||
182 | 882 | lists of strings, which will be compared to the 'name' and | ||
183 | 883 | 'file' (path) attributes of 'identifier'. | ||
184 | 884 | |||
185 | 885 | These strings may contain wildcards: e.g: | ||
186 | 886 | .*substring.* | ||
187 | 887 | sub.*string | ||
188 | 888 | etc. | ||
189 | 889 | |||
190 | 890 | """ | ||
191 | 891 | if ':' in search: | ||
192 | 892 | func_subs, file_subs = search.split(':') | ||
193 | 893 | else: | ||
194 | 894 | func_subs, file_subs = search, '' | ||
195 | 895 | |||
196 | 896 | # Remove empty strings. | ||
197 | 897 | func_subs = [sub for sub in func_subs.split(',') if sub] | ||
198 | 898 | file_subs = [sub for sub in file_subs.split(',') if sub] | ||
199 | 899 | |||
200 | 900 | # Cases for search: | ||
201 | 901 | # <specific name>:<redundant stuff> | ||
202 | 902 | # <wildcard name>:<specific filepath> | ||
203 | 903 | # <wildcard name>:<wildcard filepath> | ||
204 | 904 | |||
205 | 905 | query_str = "" | ||
206 | 906 | |||
207 | 907 | def get_list(subs): | ||
208 | 908 | return '[{}]'.format(','.join("'{}'".format(s) for s in subs)) | ||
209 | 909 | |||
210 | 910 | |||
211 | 911 | if func_subs and not any('*' in sub for sub in func_subs): | ||
212 | 912 | # List of specific functions. Don't care about anything after ':' | ||
213 | 913 | query_str += ('USING INDEX {0}:func(name) WHERE {0}.name IN {1} ' | ||
214 | 914 | .format(identifier, get_list(func_subs))) | ||
215 | 915 | else: | ||
216 | 916 | if file_subs and not any('*' in sub for sub in file_subs): | ||
217 | 917 | # Specific file to look in. | ||
218 | 918 | query_str = ('USING INDEX {0}.func(file) WHERE {0}.file IN {1} ' | ||
219 | 919 | .format(identifier, get_list(file_subs))) | ||
220 | 920 | elif file_subs: | ||
221 | 921 | query_str = ('WHERE ANY (s_file IN {} WHERE {}.file =~ s_file) ' | ||
222 | 922 | .format(get_list(file_subs), identifier)) | ||
223 | 923 | |||
224 | 924 | if func_subs: | ||
225 | 925 | query_str += 'AND ' if file_subs else 'WHERE ' | ||
226 | 926 | query_str += ('ANY (s_name IN {} WHERE {}.name =~ s_name) ' | ||
227 | 927 | .format(get_list(func_subs), identifier)) | ||
228 | 928 | |||
229 | 929 | return query_str | ||
230 | 930 | |||
231 | 857 | def get_all_functions_called(self, program_name, function_calling): | 931 | def get_all_functions_called(self, program_name, function_calling): |
232 | 858 | """ | 932 | """ |
233 | 859 | Execute query to find all functions called by a function (indirectly). | 933 | Execute query to find all functions called by a function (indirectly). |
234 | @@ -863,14 +937,9 @@ | |||
235 | 863 | :param function_calling: string name of a function whose children to find | 937 | :param function_calling: string name of a function whose children to find |
236 | 864 | :return: FunctionQueryResult, maximal subgraph rooted at function_calling | 938 | :return: FunctionQueryResult, maximal subgraph rooted at function_calling |
237 | 865 | """ | 939 | """ |
246 | 866 | 940 | q = (' MATCH (p:program {{name: "{}"}})-[:subject]->(f:func) {}' | |
247 | 867 | if not self.check_function_exists(program_name, function_calling): | 941 | ' MATCH (f)-[:calls]->(g:func) RETURN distinct f, g' |
248 | 868 | return None | 942 | .format(program_name, SextantConnection.get_query('f', function_calling))) |
241 | 869 | |||
242 | 870 | q = (' MATCH (p:program {{name: "{}"}})-[:subject]->(f:func {{name: "{}"}})' | ||
243 | 871 | ' USING INDEX f:func(name)' | ||
244 | 872 | ' MATCH (f)-[:calls*]->(g) RETURN distinct f, g' | ||
245 | 873 | .format(program_name, function_calling)) | ||
249 | 874 | 943 | ||
250 | 875 | return self._execute_query(program_name, q) | 944 | return self._execute_query(program_name, q) |
251 | 876 | 945 | ||
252 | @@ -884,14 +953,10 @@ | |||
253 | 884 | :return: FunctionQueryResult, maximal connected subgraph with leaf function_called | 953 | :return: FunctionQueryResult, maximal connected subgraph with leaf function_called |
254 | 885 | """ | 954 | """ |
255 | 886 | 955 | ||
264 | 887 | if not self.check_function_exists(program_name, function_called): | 956 | q = (' MATCH (p:program {{name: "{}"}})-[:subject]->(g:func) {}' |
265 | 888 | return None | 957 | ' MATCH (f)-[:calls]->(g)' |
266 | 889 | 958 | ' RETURN distinct f, g') | |
267 | 890 | q = (' MATCH (p:program {{name: "{}"}})-[:subject]->(g:func {{name: "{}"}})' | 959 | q = q.format(program_name, SextantConnection.get_query('g', function_called), program_name) |
260 | 891 | ' USING INDEX g:func(name)' | ||
261 | 892 | ' MATCH (f)-[:calls*]->(g) WHERE f.name <> "{}"' | ||
262 | 893 | ' RETURN distinct f , g') | ||
263 | 894 | q = q.format(program_name, function_called, program_name) | ||
268 | 895 | 960 | ||
269 | 896 | return self._execute_query(program_name, q) | 961 | return self._execute_query(program_name, q) |
270 | 897 | 962 | ||
271 | @@ -910,22 +975,17 @@ | |||
272 | 910 | if not self.check_program_exists(program_name): | 975 | if not self.check_program_exists(program_name): |
273 | 911 | return None | 976 | return None |
274 | 912 | 977 | ||
285 | 913 | if not self.check_function_exists(program_name, function_called): | 978 | start_q = SextantConnection.get_query('start', function_calling) |
286 | 914 | return None | 979 | end_q = SextantConnection.get_query('end', function_called) |
287 | 915 | 980 | ||
288 | 916 | if not self.check_function_exists(program_name, function_calling): | 981 | q = (' MATCH (p:program {{name: "{}"}})' |
289 | 917 | return None | 982 | ' MATCH (p)-[:subject]->(start:func) {} WITH start, p' |
290 | 918 | 983 | ' MATCH (p)-[:subject]->(end:func) {} WITH start, end' | |
281 | 919 | q = (' MATCH (p:program {{name: "{}"}})-[:subject]->(start:func {{name: "{}"}})' | ||
282 | 920 | ' USING INDEX start:func(name)' | ||
283 | 921 | ' MATCH (p)-[:subject]->(end:func {{name: "{}"}})' | ||
284 | 922 | ' USING INDEX end:func(name)' | ||
291 | 923 | ' MATCH path=(start)-[:calls*]->(end)' | 984 | ' MATCH path=(start)-[:calls*]->(end)' |
292 | 924 | ' WITH DISTINCT nodes(path) AS result' | 985 | ' WITH DISTINCT nodes(path) AS result' |
293 | 925 | ' UNWIND result AS answer' | 986 | ' UNWIND result AS answer' |
294 | 926 | ' RETURN answer') | 987 | ' RETURN answer') |
297 | 927 | q = q.format(program_name, function_calling, function_called) | 988 | q = q.format(program_name, start_q, end_q) |
296 | 928 | |||
298 | 929 | return self._execute_query(program_name, q) | 989 | return self._execute_query(program_name, q) |
299 | 930 | 990 | ||
300 | 931 | def get_whole_program(self, program_name): | 991 | def get_whole_program(self, program_name): |
301 | @@ -942,7 +1002,7 @@ | |||
302 | 942 | ' RETURN (f)'.format(program_name)) | 1002 | ' RETURN (f)'.format(program_name)) |
303 | 943 | return self._execute_query(program_name, q) | 1003 | return self._execute_query(program_name, q) |
304 | 944 | 1004 | ||
306 | 945 | def get_shortest_path_between_functions(self, program_name, func1, func2): | 1005 | def get_shortest_path_between_functions(self, program_name, function_calling, function_called): |
307 | 946 | """ | 1006 | """ |
308 | 947 | Execute query to get a single, shortest, path between two functions. | 1007 | Execute query to get a single, shortest, path between two functions. |
309 | 948 | :param program_name: string name of the program we wish to search under | 1008 | :param program_name: string name of the program we wish to search under |
310 | @@ -953,17 +1013,16 @@ | |||
311 | 953 | if not self.check_program_exists(program_name): | 1013 | if not self.check_program_exists(program_name): |
312 | 954 | return None | 1014 | return None |
313 | 955 | 1015 | ||
326 | 956 | if not self.check_function_exists(program_name, func1): | 1016 | start_q = SextantConnection.get_query('start', function_calling) |
327 | 957 | return None | 1017 | end_q = SextantConnection.get_query('end', function_called) |
328 | 958 | 1018 | ||
329 | 959 | if not self.check_function_exists(program_name, func2): | 1019 | q = (' MATCH (p:program {{name: "{}"}})' |
330 | 960 | return None | 1020 | ' MATCH (p)-[:subject]->(start:func) {} WITH start, p' |
331 | 961 | 1021 | ' MATCH (p)-[:subject]->(end:func) {} WITH start, end' | |
332 | 962 | q = (' MATCH (p:program {{name: "{}"}})-[:subject]->(f:func {{name: "{}"}})' | 1022 | ' MATCH path=shortestPath((start)-[:calls*]->(end))' |
333 | 963 | ' USING INDEX f:func(name)' | 1023 | ' UNWIND nodes(path) AS answer' |
334 | 964 | ' MATCH (p)-[:subject]->(g:func {{name: "{}"}})' | 1024 | ' RETURN answer') |
335 | 965 | ' MATCH path=shortestPath((f)-[:calls*]->(g))' | 1025 | q = q.format(program_name, start_q, end_q) |
324 | 966 | ' UNWIND nodes(path) AS ans' | ||
325 | 967 | ' RETURN ans'.format(program_name, func1, func2)) | ||
336 | 968 | 1026 | ||
337 | 969 | return self._execute_query(program_name, q) | 1027 | return self._execute_query(program_name, q) |
338 | 1028 | |||
339 | 970 | 1029 | ||
340 | === modified file 'src/sextant/export.py' | |||
341 | --- src/sextant/export.py 2014-10-13 14:58:12 +0000 | |||
342 | +++ src/sextant/export.py 2014-11-19 10:32:48 +0000 | |||
343 | @@ -48,7 +48,7 @@ | |||
344 | 48 | for func in program.get_functions(): | 48 | for func in program.get_functions(): |
345 | 49 | if func.type == "stub": | 49 | if func.type == "stub": |
346 | 50 | output_str += ' "{}" [fillcolor=pink, style=filled]\n'.format(func.name) | 50 | output_str += ' "{}" [fillcolor=pink, style=filled]\n'.format(func.name) |
348 | 51 | elif func.type == "function_pointer": | 51 | elif func.type == "pointer": |
349 | 52 | output_str += ' "{}" [fillcolor=yellow, style=filled]\n'.format(func.name) | 52 | output_str += ' "{}" [fillcolor=yellow, style=filled]\n'.format(func.name) |
350 | 53 | 53 | ||
351 | 54 | # in all cases, even if we've specified that we want a filled-in | 54 | # in all cases, even if we've specified that we want a filled-in |
352 | 55 | 55 | ||
353 | === modified file 'src/sextant/objdump_parser.py' (properties changed: +x to -x) | |||
354 | --- src/sextant/objdump_parser.py 2014-10-23 11:15:48 +0000 | |||
355 | +++ src/sextant/objdump_parser.py 2014-11-19 10:32:48 +0000 | |||
356 | @@ -42,9 +42,12 @@ | |||
357 | 42 | The number of function calls that have been parsed. | 42 | The number of function calls that have been parsed. |
358 | 43 | function_ptr_count: | 43 | function_ptr_count: |
359 | 44 | The number of function pointers that have been detected. | 44 | The number of function pointers that have been detected. |
363 | 45 | _known_stubs: | 45 | _known_functions: |
364 | 46 | A set of the names of functions with type 'stub' that have been | 46 | A set of the names of functions that have been |
365 | 47 | parsed - used to avoid registering a stub multiple times. | 47 | parsed - used to avoid registering a function multiple times. |
366 | 48 | _partial_functions: | ||
367 | 49 | A set of functions whose names we have seen but whose source | ||
368 | 50 | files we don't yet know. | ||
369 | 48 | 51 | ||
370 | 49 | """ | 52 | """ |
371 | 50 | def __init__(self, file_path, file_object=None, | 53 | def __init__(self, file_path, file_object=None, |
372 | @@ -102,13 +105,14 @@ | |||
373 | 102 | self.call_count = 0 | 105 | self.call_count = 0 |
374 | 103 | self.function_ptr_count = 0 | 106 | self.function_ptr_count = 0 |
375 | 104 | 107 | ||
379 | 105 | # Avoid adding duplicate function stubs (as these are detected from | 108 | # Avoid adding duplicate functions. |
380 | 106 | # function calls so may be repeated). | 109 | self._known_functions = set() |
381 | 107 | self._known_stubs = set() | 110 | # Set of partially-parsed functions. |
382 | 111 | self._partial_functions = set() | ||
383 | 108 | 112 | ||
384 | 109 | # By default print information to stdout. | 113 | # By default print information to stdout. |
387 | 110 | def print_func(name, typ): | 114 | def print_func(name, typ, source='unknown'): |
388 | 111 | print('func {:25}{}'.format(name, typ)) | 115 | print('func {:25}{:15}{}'.format(name, typ, source)) |
389 | 112 | 116 | ||
390 | 113 | def print_call(caller, callee): | 117 | def print_call(caller, callee): |
391 | 114 | print('call {:25}{:25}'.format(caller, callee)) | 118 | print('call {:25}{:25}'.format(caller, callee)) |
392 | @@ -116,7 +120,6 @@ | |||
393 | 116 | def print_started(parser): | 120 | def print_started(parser): |
394 | 117 | print('parse started: {}[{}]'.format(self.path, ', '.join(self.sections))) | 121 | print('parse started: {}[{}]'.format(self.path, ', '.join(self.sections))) |
395 | 118 | 122 | ||
396 | 119 | |||
397 | 120 | def print_finished(parser): | 123 | def print_finished(parser): |
398 | 121 | print('parsed {} functions and {} calls'.format(self.function_count, self.call_count)) | 124 | print('parsed {} functions and {} calls'.format(self.function_count, self.call_count)) |
399 | 122 | 125 | ||
400 | @@ -134,12 +137,32 @@ | |||
401 | 134 | self.function_ptr_count += 1 | 137 | self.function_ptr_count += 1 |
402 | 135 | return name | 138 | return name |
403 | 136 | 139 | ||
410 | 137 | def _add_function_normal(self, name): | 140 | def _add_function(self, name, source=None): |
411 | 138 | """ | 141 | """ |
412 | 139 | Add a function which we have full assembly code for. | 142 | Add a partially known or fully known function. |
413 | 140 | """ | 143 | """ |
414 | 141 | self.add_function(name, 'normal') | 144 | if source is None: |
415 | 142 | self.function_count += 1 | 145 | # Partial definition - if do not already have a full definition |
416 | 146 | # for this name then add it to the partials set. | ||
417 | 147 | if not name in self._known_functions: | ||
418 | 148 | self._partial_functions.add(name) | ||
419 | 149 | elif source == 'unknown': | ||
420 | 150 | # Manually adding a stub function. | ||
421 | 151 | self.add_function(name, 'stub', source) | ||
422 | 152 | self.function_count += 1 | ||
423 | 153 | elif name not in self._known_functions: | ||
424 | 154 | # A full definition - either upgrade from partial function | ||
425 | 155 | # to known function, or add directly to known functions | ||
426 | 156 | # (otherwise we have already seen it) | ||
427 | 157 | |||
428 | 158 | try: | ||
429 | 159 | self._partial_functions.remove(name) | ||
430 | 160 | except KeyError: | ||
431 | 161 | pass | ||
432 | 162 | |||
433 | 163 | self._known_functions.add(name) | ||
434 | 164 | self.add_function(name, 'normal', source) | ||
435 | 165 | self.function_count += 1 | ||
436 | 143 | 166 | ||
437 | 144 | def _add_function_ptr(self, name): | 167 | def _add_function_ptr(self, name): |
438 | 145 | """ | 168 | """ |
439 | @@ -148,15 +171,6 @@ | |||
440 | 148 | self.add_function(name, 'pointer') | 171 | self.add_function(name, 'pointer') |
441 | 149 | self.function_count += 1 | 172 | self.function_count += 1 |
442 | 150 | 173 | ||
443 | 151 | def _add_function_stub(self, name): | ||
444 | 152 | """ | ||
445 | 153 | Add a function stub - we have its name but none of its internals. | ||
446 | 154 | """ | ||
447 | 155 | if not name in self._known_stubs: | ||
448 | 156 | self._known_stubs.add(name) | ||
449 | 157 | self.add_function(name, 'stub') | ||
450 | 158 | self.function_count += 1 | ||
451 | 159 | |||
452 | 160 | def _add_call(self, caller, callee): | 174 | def _add_call(self, caller, callee): |
453 | 161 | """ | 175 | """ |
454 | 162 | Add a function call from caller to callee. | 176 | Add a function call from caller to callee. |
455 | @@ -171,10 +185,20 @@ | |||
456 | 171 | self.started() | 185 | self.started() |
457 | 172 | 186 | ||
458 | 173 | if self._file is not None: | 187 | if self._file is not None: |
461 | 174 | in_section = False # if we are in one of self.sections | 188 | in_section = False # If we are in one of self.sections. |
462 | 175 | current_function = None # track the caller for function calls | 189 | current_function = None # Track the caller for function calls. |
463 | 190 | to_add = False | ||
464 | 176 | 191 | ||
465 | 177 | for line in self._file: | 192 | for line in self._file: |
466 | 193 | if to_add: | ||
467 | 194 | file_line = line.startswith('/') | ||
468 | 195 | source = line.split(':')[0] if file_line else None | ||
469 | 196 | self._add_function(current_function, source) | ||
470 | 197 | to_add = False | ||
471 | 198 | |||
472 | 199 | if file_line: | ||
473 | 200 | continue | ||
474 | 201 | |||
475 | 178 | if line.startswith('Disassembly'): | 202 | if line.startswith('Disassembly'): |
476 | 179 | # 'Disassembly of section <name>:\n' | 203 | # 'Disassembly of section <name>:\n' |
477 | 180 | section = line.split(' ')[-1].rstrip(':\n') | 204 | section = line.split(' ')[-1].rstrip(':\n') |
478 | @@ -189,12 +213,19 @@ | |||
479 | 189 | # <function_name>[@plt] | 213 | # <function_name>[@plt] |
480 | 190 | function_identifier = line.split('<')[-1].split('>')[0] | 214 | function_identifier = line.split('<')[-1].split('>')[0] |
481 | 191 | 215 | ||
482 | 216 | # IOS builds add a __be_ (big endian) prefix to all functions, | ||
483 | 217 | # get rid of it if it is there, | ||
484 | 218 | if function_identifier.startswith('__be_'): | ||
485 | 219 | function_identifier = function_identifier.lstrip('__be_') | ||
486 | 220 | |||
487 | 192 | if '@' in function_identifier: | 221 | if '@' in function_identifier: |
488 | 222 | # Of form <function name>@<other stuff>. | ||
489 | 193 | current_function = function_identifier.split('@')[0] | 223 | current_function = function_identifier.split('@')[0] |
491 | 194 | self._add_function_stub(current_function) | 224 | self._add_function(current_function) |
492 | 195 | else: | 225 | else: |
493 | 196 | current_function = function_identifier | 226 | current_function = function_identifier |
495 | 197 | self._add_function_normal(current_function) | 227 | # Flag function - we look for source on the next line. |
496 | 228 | to_add = True | ||
497 | 198 | 229 | ||
498 | 199 | elif 'call ' in line or 'callq ' in line: | 230 | elif 'call ' in line or 'callq ' in line: |
499 | 200 | # WHITESPACE to prevent picking up function names | 231 | # WHITESPACE to prevent picking up function names |
500 | @@ -213,9 +244,12 @@ | |||
501 | 213 | # from which we extract name | 244 | # from which we extract name |
502 | 214 | callee_is_ptr = False | 245 | callee_is_ptr = False |
503 | 215 | function_identifier = callee_info.lstrip('<').rstrip('>\n') | 246 | function_identifier = callee_info.lstrip('<').rstrip('>\n') |
504 | 247 | if function_identifier.startswith('__be_'): | ||
505 | 248 | function_identifier = function_identifier.lstrip('__be_') | ||
506 | 249 | |||
507 | 216 | if '@' in function_identifier: | 250 | if '@' in function_identifier: |
508 | 217 | callee = function_identifier.split('@')[0] | 251 | callee = function_identifier.split('@')[0] |
510 | 218 | self._add_function_stub(callee) | 252 | self._add_function(callee) |
511 | 219 | else: | 253 | else: |
512 | 220 | callee = function_identifier.split('-')[-1].split('+')[0] | 254 | callee = function_identifier.split('-')[-1].split('+')[0] |
513 | 221 | # Do not add this fn now - it is a normal func | 255 | # Do not add this fn now - it is a normal func |
514 | @@ -231,6 +265,10 @@ | |||
515 | 231 | # Add the call. | 265 | # Add the call. |
516 | 232 | if not (self.ignore_ptrs and callee_is_ptr): | 266 | if not (self.ignore_ptrs and callee_is_ptr): |
517 | 233 | self._add_call(current_function, callee) | 267 | self._add_call(current_function, callee) |
518 | 268 | |||
519 | 269 | for name in self._partial_functions: | ||
520 | 270 | self._add_function(name, 'unknown') | ||
521 | 271 | |||
522 | 234 | 272 | ||
523 | 235 | self.finished() | 273 | self.finished() |
524 | 236 | 274 | ||
525 | @@ -261,7 +299,7 @@ | |||
526 | 261 | return result | 299 | return result |
527 | 262 | 300 | ||
528 | 263 | 301 | ||
530 | 264 | def run_objdump(input_file): | 302 | def run_objdump(input_file, add_file_paths=False): |
531 | 265 | """ | 303 | """ |
532 | 266 | Run the objdump command on the file with the given path. | 304 | Run the objdump command on the file with the given path. |
533 | 267 | 305 | ||
534 | @@ -271,13 +309,24 @@ | |||
535 | 271 | Arguments: | 309 | Arguments: |
536 | 272 | input_file: | 310 | input_file: |
537 | 273 | The path of the file to run objdump on. | 311 | The path of the file to run objdump on. |
538 | 312 | add_file_paths: | ||
539 | 313 | Whether to call with -l option to extract line numbers and source | ||
540 | 314 | files from the binary. VERY SLOW on large binaries (~15 hours for ios). | ||
541 | 274 | 315 | ||
542 | 275 | """ | 316 | """ |
543 | 317 | print('input file: {}'.format(input_file)) | ||
544 | 276 | # A single section can be specified for parsing with the -j flag, | 318 | # A single section can be specified for parsing with the -j flag, |
545 | 277 | # but it is not obviously possible to parse multiple sections like this. | 319 | # but it is not obviously possible to parse multiple sections like this. |
549 | 278 | p = subprocess.Popen(['objdump', '-d', input_file, '--no-show-raw-insn'], | 320 | args = ['objdump', '-d', input_file, '--no-show-raw-insn'] |
550 | 279 | stdout=subprocess.PIPE) | 321 | if add_file_paths: |
551 | 280 | g = subprocess.Popen(['egrep', 'Disassembly|call(q)? |>:$'], stdin=p.stdout, stdout=subprocess.PIPE) | 322 | args += ['--line-numbers'] |
552 | 323 | |||
553 | 324 | p = subprocess.Popen(args, stdout=subprocess.PIPE) | ||
554 | 325 | # Egrep filters out the section headers (Disassembly of section...), | ||
555 | 326 | # the call lines (... [l]call[q] ...), the function declarations | ||
556 | 327 | # (... <function>:$) and the file paths (^/file_path). | ||
557 | 328 | g = subprocess.Popen(['egrep', 'Disassembly|call(q)? |>:$|^/'], | ||
558 | 329 | stdin=p.stdout, stdout=subprocess.PIPE) | ||
559 | 281 | return input_file, g.stdout | 330 | return input_file, g.stdout |
560 | 282 | 331 | ||
561 | 283 | 332 | ||
562 | 284 | 333 | ||
563 | === modified file 'src/sextant/test_parser.py' | |||
564 | --- src/sextant/test_parser.py 2014-10-23 11:15:48 +0000 | |||
565 | +++ src/sextant/test_parser.py 2014-11-19 10:32:48 +0000 | |||
566 | @@ -23,7 +23,7 @@ | |||
567 | 23 | calls = defaultdict(list) | 23 | calls = defaultdict(list) |
568 | 24 | 24 | ||
569 | 25 | # set the Parser to put output in local dictionaries | 25 | # set the Parser to put output in local dictionaries |
571 | 26 | add_function = lambda n, t: self.add_function(functions, n, t) | 26 | add_function = lambda n, t, s='unknown': self.add_function(functions, n, t) |
572 | 27 | add_call = lambda a, b: self.add_call(calls, a, b) | 27 | add_call = lambda a, b: self.add_call(calls, a, b) |
573 | 28 | 28 | ||
574 | 29 | p = parser.Parser(path, sections=sections, ignore_ptrs=ignore_ptrs, | 29 | p = parser.Parser(path, sections=sections, ignore_ptrs=ignore_ptrs, |
575 | 30 | 30 | ||
576 | === modified file 'src/sextant/test_resources/parser_test' | |||
577 | 31 | Binary files src/sextant/test_resources/parser_test 2014-10-13 14:10:01 +0000 and src/sextant/test_resources/parser_test 2014-11-19 10:32:48 +0000 differ | 31 | Binary files src/sextant/test_resources/parser_test 2014-10-13 14:10:01 +0000 and src/sextant/test_resources/parser_test 2014-11-19 10:32:48 +0000 differ |
578 | === modified file 'src/sextant/update_db.py' | |||
579 | --- src/sextant/update_db.py 2014-10-17 14:20:06 +0000 | |||
580 | +++ src/sextant/update_db.py 2014-11-19 10:32:48 +0000 | |||
581 | @@ -20,7 +20,7 @@ | |||
582 | 20 | import logging | 20 | import logging |
583 | 21 | 21 | ||
584 | 22 | def upload_program(connection, user_name, file_path, program_name=None, | 22 | def upload_program(connection, user_name, file_path, program_name=None, |
586 | 23 | not_object_file=False): | 23 | not_object_file=False, add_file_paths=False): |
587 | 24 | """ | 24 | """ |
588 | 25 | Upload a program's functions and call graph to the database. | 25 | Upload a program's functions and call graph to the database. |
589 | 26 | 26 | ||
590 | @@ -38,6 +38,9 @@ | |||
591 | 38 | not_object_file: | 38 | not_object_file: |
592 | 39 | Flag controlling whether file_path is pointing to a dump file or | 39 | Flag controlling whether file_path is pointing to a dump file or |
593 | 40 | a binary file. | 40 | a binary file. |
594 | 41 | add_file_paths: | ||
595 | 42 | Flag controlling whether to call objdump with the -l option to | ||
596 | 43 | extract line numbers and source files. VERY SLOW on large binaries. | ||
597 | 41 | """ | 44 | """ |
598 | 42 | if not connection._ssh: | 45 | if not connection._ssh: |
599 | 43 | raise SSHConnectionError('An SSH connection is required for ' | 46 | raise SSHConnectionError('An SSH connection is required for ' |
600 | @@ -59,9 +62,9 @@ | |||
601 | 59 | start = time() | 62 | start = time() |
602 | 60 | 63 | ||
603 | 61 | if not not_object_file: | 64 | if not not_object_file: |
605 | 62 | print('Generating dump file...', end='') | 65 | print('Generating dump file with{} file paths...'.format(('out', '')[add_file_paths]), end='') |
606 | 63 | sys.stdout.flush() | 66 | sys.stdout.flush() |
608 | 64 | file_path, file_object = run_objdump(file_path) | 67 | file_path, file_object = run_objdump(file_path, add_file_paths) |
609 | 65 | print('done.') | 68 | print('done.') |
610 | 66 | else: | 69 | else: |
611 | 67 | file_object = None | 70 | file_object = None |
612 | @@ -82,15 +85,19 @@ | |||
613 | 82 | print('done: {} functions and {} calls.' | 85 | print('done: {} functions and {} calls.' |
614 | 83 | .format(parser.function_count, parser.call_count)) | 86 | .format(parser.function_count, parser.call_count)) |
615 | 84 | 87 | ||
617 | 85 | parser = Parser(file_path = file_path, file_object = file_object, | 88 | parser = Parser(file_path=file_path, file_object = file_object, |
618 | 86 | sections=[], | 89 | sections=[], |
621 | 87 | add_function = program.add_function, | 90 | add_function=program.add_function, |
622 | 88 | add_call = program.add_call, | 91 | add_call=program.add_call, |
623 | 89 | started=lambda parser: start_parser(program), | 92 | started=lambda parser: start_parser(program), |
624 | 90 | finished=lambda parser: finish_parser(parser, program)) | 93 | finished=lambda parser: finish_parser(parser, program)) |
625 | 94 | |||
626 | 91 | parser.parse() | 95 | parser.parse() |
629 | 92 | 96 | ||
630 | 93 | program.commit() | 97 | if parser.function_count == 0: |
631 | 98 | print('Nothing to upload. Did you mean to add the --not-object-file flag?') | ||
632 | 99 | else: | ||
633 | 100 | program.commit() | ||
634 | 94 | 101 | ||
635 | 95 | end = time() | 102 | end = time() |
636 | 96 | print('Finished in {:.2f}s.'.format(end-start)) | 103 | print('Finished in {:.2f}s.'.format(end-start)) |
637 | 97 | 104 | ||
638 | === modified file 'src/sextant/web/server.py' | |||
639 | --- src/sextant/web/server.py 2014-11-19 10:32:48 +0000 | |||
640 | +++ src/sextant/web/server.py 2014-11-19 10:32:48 +0000 | |||
641 | @@ -13,6 +13,8 @@ | |||
642 | 13 | from twisted.internet.threads import deferToThread | 13 | from twisted.internet.threads import deferToThread |
643 | 14 | from twisted.internet import defer | 14 | from twisted.internet import defer |
644 | 15 | 15 | ||
645 | 16 | from neo4jrestclient.exceptions import TransactionException | ||
646 | 17 | |||
647 | 16 | import logging | 18 | import logging |
648 | 17 | import os | 19 | import os |
649 | 18 | import json | 20 | import json |
650 | @@ -24,6 +26,8 @@ | |||
651 | 24 | import tempfile | 26 | import tempfile |
652 | 25 | import subprocess | 27 | import subprocess |
653 | 26 | 28 | ||
654 | 29 | from datetime import datetime | ||
655 | 30 | |||
656 | 27 | from cgi import escape # deprecated in Python 3 in favour of html.escape, but we're stuck on Python 2 | 31 | from cgi import escape # deprecated in Python 3 in favour of html.escape, but we're stuck on Python 2 |
657 | 28 | 32 | ||
658 | 29 | # global SextantConnection object which deals with the port forwarding | 33 | # global SextantConnection object which deals with the port forwarding |
659 | @@ -174,13 +178,15 @@ | |||
660 | 174 | # if we are okay here we have a valid query with all required arguments | 178 | # if we are okay here we have a valid query with all required arguments |
661 | 175 | if res_code is RESPONSE_CODE_OK: | 179 | if res_code is RESPONSE_CODE_OK: |
662 | 176 | try: | 180 | try: |
663 | 181 | print('running query {}'.format(datetime.now())) | ||
664 | 177 | program = yield defer_to_thread_with_timeout(render_timeout, fn, | 182 | program = yield defer_to_thread_with_timeout(render_timeout, fn, |
665 | 178 | name, *req_args) | 183 | name, *req_args) |
667 | 179 | except defer.CancelledError: | 184 | print('\tdone {}'.format(datetime.now())) |
668 | 185 | except Exception as e: | ||
669 | 180 | # the timeout has fired and cancelled the request | 186 | # the timeout has fired and cancelled the request |
670 | 181 | res_code = RESPONSE_CODE_BAD_REQUEST | 187 | res_code = RESPONSE_CODE_BAD_REQUEST |
673 | 182 | res_fmt = "The request timed out after {} seconds." | 188 | res_msg = "{}".format(e) |
674 | 183 | res_msg = res_fmt.format(render_timeout) | 189 | print('\tfailed {}'.format(datetime.now())) |
675 | 184 | 190 | ||
676 | 185 | if res_code is RESPONSE_CODE_OK: | 191 | if res_code is RESPONSE_CODE_OK: |
677 | 186 | # we have received a response to our request | 192 | # we have received a response to our request |
678 | @@ -201,10 +207,12 @@ | |||
679 | 201 | suppress_common = suppress_common_arg in ('null', 'true') | 207 | suppress_common = suppress_common_arg in ('null', 'true') |
680 | 202 | 208 | ||
681 | 203 | # we have a non-empty return - render it | 209 | # we have a non-empty return - render it |
682 | 210 | print('getting plot {}'.format(datetime.now())) | ||
683 | 204 | res_msg = yield deferToThread(self.get_plot, program, | 211 | res_msg = yield deferToThread(self.get_plot, program, |
684 | 205 | suppress_common, | 212 | suppress_common, |
685 | 206 | remove_self_calls=False) | 213 | remove_self_calls=False) |
686 | 207 | request.setHeader('content-type', 'image/svg+xml') | 214 | request.setHeader('content-type', 'image/svg+xml') |
687 | 215 | print('\tdone {}'.format(datetime.now())) | ||
688 | 208 | 216 | ||
689 | 209 | request.setResponseCode(res_code) | 217 | request.setResponseCode(res_code) |
690 | 210 | request.write(res_msg) | 218 | request.write(res_msg) |
691 | @@ -229,6 +237,7 @@ | |||
692 | 229 | max_funcs = AUTOCOMPLETE_NAMES_LIMIT + 1 | 237 | max_funcs = AUTOCOMPLETE_NAMES_LIMIT + 1 |
693 | 230 | programs = CONNECTION.programs_with_metadata() | 238 | programs = CONNECTION.programs_with_metadata() |
694 | 231 | result = CONNECTION.get_function_names(program_name, search, max_funcs) | 239 | result = CONNECTION.get_function_names(program_name, search, max_funcs) |
695 | 240 | print(search, len(result)) | ||
696 | 232 | return result if len(result) < max_funcs else set() | 241 | return result if len(result) < max_funcs else set() |
697 | 233 | 242 | ||
698 | 234 | 243 |