ricgraph 2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ricgraph/ricgraph.py ADDED
@@ -0,0 +1,2866 @@
1
+ # ########################################################################
2
+ #
3
+ # Ricgraph - Research in context graph
4
+ #
5
+ # ########################################################################
6
+ #
7
+ # MIT License
8
+ #
9
+ # Copyright (c) 2023, 2024, 2025 Rik D.T. Janssen
10
+ #
11
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ # of this software and associated documentation files (the "Software"), to deal
13
+ # in the Software without restriction, including without limitation the rights
14
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ # copies of the Software, and to permit persons to whom the Software is
16
+ # furnished to do so, subject to the following conditions:
17
+ #
18
+ # The above copyright notice and this permission notice shall be included in all
19
+ # copies or substantial portions of the Software.
20
+ #
21
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ # SOFTWARE.
28
+ #
29
+ # ########################################################################
30
+ #
31
+ # This file is the main file for Ricgraph.
32
+ # Original version Rik D.T. Janssen, December 2022.
33
+ # Updated Rik D.T. Janssen, February, March, September to December 2023.
34
+ # Updated Rik D.T. Janssen, February, March 2024.
35
+ # Updated Rik D.T. Janssen, January 2025
36
+ #
37
+ # ########################################################################
38
+
39
+
40
+ """Ricgraph - Research in context graph.
41
+
42
+ This is Ricgraph - Research in context graph, a graph structure with nodes and edges
43
+ to represent information (as properties in the nodes)
44
+ and their relations (the edges). You can find the code and documentation on
45
+ https://github.com/UtrechtUniversity/ricgraph/.
46
+
47
+ Ricgraph uses Neo4j as graph database, https://neo4j.com.
48
+ Neo4j only allows directed graphs, so Ricgraph is a directed graph.
49
+ They have several products. Their free products are:
50
+
51
+ - Neo4j Desktop (https://neo4j.com/download-center/#desktop);
52
+
53
+ - Neo4j Community Edition (https://neo4j.com/download-center/#community).
54
+
55
+ Neo4j Desktop includes Bloom, which allows exploring the graph in a more
56
+ intuitive way. Community Edition allows only Cypher queries.
57
+
58
+ Ricgraph can be extended for other brands of graph databases
59
+ by changing minor bits of the code.
60
+
61
+ All nodes have the properties in RICGRAPH_PROPERTIES_STANDARD and
62
+ RICGRAPH_PROPERTIES_HIDDEN (see ricgraph.ini):
63
+
64
+ In RICGRAPH_PROPERTIES_STANDARD:
65
+
66
+ - name: name of the node, e.g. ISNI, ORCID, DOI, etc.
67
+
68
+ - category: category of the node,
69
+ e.g. person, person-root, book, journal article, dataset, etc.
70
+
71
+ - value: value of the node.
72
+
73
+ In RICGRAPH_PROPERTIES_HIDDEN:
74
+
75
+ - _key: key value of the node, not to be modified by the user.
76
+
77
+ - _source: sorted list of sources a record has been harvested from,
78
+ not to be modified by the user
79
+
80
+ - _history: list of history events of the node, not to be modified by the user.
81
+
82
+ Additional properties for nodes can be added by changing entry RICGRAPH_PROPERTIES_ADDITIONAL
83
+ in ricgraph.ini (make sure 'history_event' is last).
84
+ In the default configuration of Ricgraph, the following properties are included:
85
+
86
+ - comment: comment for a node, can be anything that is thought to be useful.
87
+
88
+ - url_main: main URL for a node, pointing to e.g. the corresponding ISNI, ORCID or DOI
89
+ record on the web.
90
+
91
+ - url_other: other URL for a node, pointing to e.g. the originating record in a source system.
92
+
93
+ - year: year of a research output.
94
+
95
+ - source_event: an event to be added to the _source list.
96
+
97
+ - history_event: an event to be added to the _history list.
98
+ """
99
+
100
+
101
+ import os
102
+ import sys
103
+ from datetime import datetime
104
+ import numpy
105
+ import pandas
106
+ import requests
107
+ import csv
108
+ import uuid
109
+ import json
110
+ from typing import Union, Tuple
111
+ from collections import defaultdict
112
+ import configparser
113
+ from neo4j import GraphDatabase, Driver, Result
114
+ from neo4j.graph import Node
115
+
116
+ __author__ = 'Rik D.T. Janssen'
117
+ __copyright__ = 'Copyright (c) 2023, 2024, 2025 Rik D.T. Janssen'
118
+ __email__ = ''
119
+ __license__ = 'MIT License'
120
+ __package__ = 'Ricgraph'
121
+ # __version__ is in __init__.py of this module.
122
+
123
+
124
+ # ########################################################################
125
+ # Start of constants section
126
+ # ########################################################################
127
+ RICGRAPH_INI_FILENAME = 'ricgraph.ini'
128
+ RICGRAPH_KEY_SEPARATOR = '|'
129
+
130
+ # Used for some loop iterations, in case no max iteration for such a loop is specified.
131
+ A_LARGE_NUMBER = 9999999999
132
+
133
+ # For the REST API, we need to return an HTTP response status code. These are
134
+ # listed on https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml.
135
+ # For Ricgraph, we only use one HTTP response status code:
136
+ # https://www.rfc-editor.org/rfc/rfc9110.html#name-200-ok.
137
+ HTTP_RESPONSE_OK = 200
138
+ # We use these if an item cannot be found. They should be in the 2xx series
139
+ # since we still return a valid HTTP response. These are in the "unassigned" range,
140
+ # which may be freely used.
141
+ HTTP_RESPONSE_NOTHING_FOUND = 250
142
+ HTTP_RESPONSE_INVALID_SEARCH = 251
143
+
144
+ # This dict is used as a cache for node id's. If we have a node id, we can
145
+ # do a direct lookup for a node in O(1), instead of a search in O(log n).
146
+ # The dict has the format: [Ricgraph _key]: [Node element_id].
147
+ nodes_cache_key_element_id = {}
148
+ # Cache size. 'nodes_cache_key_element_id' will be emptied if it has this number of elements.
149
+ MAX_NODES_CACHE_KEY_ID = 15000
150
+
151
+
152
+ # These counters are used to count the number of accesses to the
153
+ # graph database backend.
154
+ graphdb_nr_creates = 0
155
+ graphdb_nr_reads = 0
156
+ graphdb_nr_updates = 0
157
+ graphdb_nr_deletes = 0
158
+
159
+
160
+ # ########################################################################
161
+ # Research output types used in Ricgraph.
162
+ # Harvested sources most often have a type for a research output.
163
+ # In a harvest script for a certain source, you specify a mapping
164
+ # from the names used in the source you harvest, to the values below.
165
+ # This ensures that Ricgraph uses the same wording for types of
166
+ # research outputs harvested from different sources.
167
+ #
168
+ # An example mapping may look like this:
169
+ # mapping = {
170
+ # 'book': ROTYPE_BOOK,
171
+ # 'dataset': ROTYPE_DATASET,
172
+ # 'software': ROTYPE_SOFTWARE
173
+ # }
174
+ #
175
+ # Call the function lookup_resout_type() to do this mapping.
176
+ # If you add a type, also add it to ROTYPE_ALL.
177
+ #
178
+ # This list is inspired by the Strategy Evaluation Protocol 2021-2027
179
+ # https://www.universiteitenvannederland.nl/files/documenten/Domeinen/Onderzoek/SEP_2021-2027.pdf,
180
+ # Appendix E2.
181
+ ROTYPE_ABSTRACT = 'abstract'
182
+ ROTYPE_BOOK = 'book'
183
+ ROTYPE_BOOKCHAPTER = 'book chapter'
184
+ ROTYPE_CONFERENCE_ARTICLE = 'conference article'
185
+ ROTYPE_DATASET = 'data set'
186
+ ROTYPE_DESIGN = 'design'
187
+ ROTYPE_DIGITAL_VISUAL_PRODUCT = 'digital or visual product'
188
+ ROTYPE_EDITORIAL = 'editorial'
189
+ ROTYPE_ENTRY = 'entry for encyclopedia or dictionary'
190
+ ROTYPE_EXHIBITION_PERFORMANCE = 'exhibition or performance'
191
+ ROTYPE_JOURNAL_ARTICLE = 'journal article'
192
+ ROTYPE_LETTER = 'letter to the editor'
193
+ ROTYPE_MEMORANDUM = 'memorandum'
194
+ ROTYPE_METHOD_DESCRIPTION = 'method description'
195
+ ROTYPE_MODEL = 'model'
196
+ ROTYPE_OTHER_CONTRIBUTION = 'other contribution'
197
+ ROTYPE_PATENT = 'patent'
198
+ ROTYPE_PHDTHESIS = 'PhD thesis'
199
+ ROTYPE_POSTER = 'poster'
200
+ ROTYPE_PREPRINT = 'preprint'
201
+ ROTYPE_PRESENTATION = 'presentation'
202
+ ROTYPE_REGISTERED_REPORT = 'registered report'
203
+ ROTYPE_REPORT = 'report'
204
+ ROTYPE_RETRACTION = 'retraction'
205
+ ROTYPE_REVIEW = 'review'
206
+ ROTYPE_SOFTWARE = 'software'
207
+ ROTYPE_THESIS = 'thesis'
208
+ ROTYPE_WEBSITE = 'website or web publication'
209
+
210
+ ROTYPE_ALL = [ROTYPE_ABSTRACT,
211
+ ROTYPE_BOOK,
212
+ ROTYPE_BOOKCHAPTER,
213
+ ROTYPE_CONFERENCE_ARTICLE,
214
+ ROTYPE_DATASET,
215
+ ROTYPE_DESIGN,
216
+ ROTYPE_DIGITAL_VISUAL_PRODUCT,
217
+ ROTYPE_EDITORIAL,
218
+ ROTYPE_ENTRY,
219
+ ROTYPE_EXHIBITION_PERFORMANCE,
220
+ ROTYPE_JOURNAL_ARTICLE,
221
+ ROTYPE_LETTER,
222
+ ROTYPE_MEMORANDUM,
223
+ ROTYPE_METHOD_DESCRIPTION,
224
+ ROTYPE_MODEL,
225
+ ROTYPE_OTHER_CONTRIBUTION,
226
+ ROTYPE_PATENT,
227
+ ROTYPE_PHDTHESIS,
228
+ ROTYPE_POSTER,
229
+ ROTYPE_PREPRINT,
230
+ ROTYPE_PRESENTATION,
231
+ ROTYPE_REGISTERED_REPORT,
232
+ ROTYPE_REPORT,
233
+ ROTYPE_RETRACTION,
234
+ ROTYPE_REVIEW,
235
+ ROTYPE_SOFTWARE,
236
+ ROTYPE_THESIS,
237
+ ROTYPE_WEBSITE]
238
+
239
+ # ########################################################################
240
+ # End of constants section
241
+ # ########################################################################
242
+
243
+
244
+ global _graph
245
+
246
+
247
+ # ##############################################################################
248
+ # Modification for Python module neo4j.
249
+ # ##############################################################################
250
+
251
+ def node_eq(self, other):
252
+ """Modified __eq__ method for Python module neo4j.
253
+
254
+ The __eq__ method of class Node does not work as expected (as of April 2024).
255
+ Two Nodes are supposed to be equal if their Node.element_id's are equal.
256
+
257
+ I would like to be able to test if a 'node' of type Node is in a list called
258
+ 'mylist' with an 'if' statement like this:
259
+ 'if node in mylist ...' or 'if node not in mylist ...',
260
+ but it does not work: even if Nodes are in that list, the 'if' fails.
261
+ I could have made my own class and extended it with an __eq__ method, but
262
+ I choose this hack. It is called a 'Monkey patch': A Monkey patch is a piece
263
+ of Python code which extends or modifies other code at runtime
264
+ (typically at startup).
265
+
266
+ :return: True on equality, or False if not.
267
+ """
268
+ return isinstance(other, Node) and self.element_id == other.element_id
269
+
270
+
271
+ # Modified __eq__ method for Python module neo4j (also see above).
272
+ Node.__eq__ = node_eq
273
+
274
+
275
+ # ##############################################################################
276
+ # Cypher functions.
277
+ # ##############################################################################
278
+ # These are also functions with cypher:
279
+ # - read_all_nodes()
280
+ # - read_all_values_of_property()
281
+ # - get_all_neighbor_nodes()
282
+ # ##############################################################################
283
+ # Note the use of WHERE clauses below. Some of them uses the function elementId(),
284
+ # which does a _direct_ lookup for a node with that id. That is the fastest way possible,
285
+ # compared to a WHERE clause on node['_key'], which is a search.
286
+ # To observe this difference, prefix a Cypher query with PROFILE, e.g.:
287
+ # PROFILE MATCH (n) WHERE elementId(n)=[a string here] RETURN *
288
+ #
289
+ # Read: https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-elementid
290
+ # https://neo4j.com/docs/cypher-manual/current/planning-and-tuning/operators/operators-detail/#query-plan-node-by-elementid-seek
291
+ #
292
+ # [May 6, 2024] Note however, that memgraph on does not have elementId() but only id():
293
+ # https://memgraph.com/docs/querying/differences-in-cypher-implementations.
294
+ # ##############################################################################
295
+
296
+ def open_ricgraph() -> Driver:
297
+ """Open Ricgraph.
298
+
299
+ :return: graph that has been opened.
300
+ """
301
+ global _graph
302
+
303
+ print('Opening ricgraph.\n')
304
+ try:
305
+ _graph = GraphDatabase.driver(GRAPHDB_URL,
306
+ auth=(GRAPHDB_USER, GRAPHDB_PASSWORD))
307
+ _graph.verify_connectivity()
308
+ except Exception as e:
309
+ print('open_ricgraph(): An exception occurred. Name: ' + type(e).__name__ + ',')
310
+ print(' error message: ' + str(e) + '.')
311
+ print('Error opening graph database backend using these parameters:')
312
+ print('GRAPHDB_URL: ' + GRAPHDB_URL)
313
+ print('GRAPHDB_DATABASENAME: ' + ricgraph_databasename())
314
+ print('GRAPHDB_USER: ' + GRAPHDB_USER)
315
+ print('GRAPHDB_PASSWORD: [see file ' + get_ricgraph_ini_file() + ']')
316
+ print('\nAre these parameters correct and did you start your graph database backend "' + GRAPHDB + '"?')
317
+ print('Exiting.')
318
+ exit(1)
319
+
320
+ return _graph
321
+
322
+
323
+ def close_ricgraph() -> None:
324
+ """Close Ricgraph.
325
+ """
326
+ global _graph
327
+
328
+ print('Closing ricgraph.\n')
329
+ _graph.close()
330
+ return
331
+
332
+
333
+ def ricgraph_database() -> str:
334
+ """Return the name of the Ricgraph database backend.
335
+ """
336
+ return GRAPHDB
337
+
338
+
339
+ def ricgraph_databasename() -> str:
340
+ """Return the name of the database in the Ricgraph database backend.
341
+ """
342
+ return GRAPHDB_DATABASENAME
343
+
344
+
345
+ def empty_ricgraph(answer: str = '') -> None:
346
+ """Empty Ricgraph and create new indexes. Side effect: indexes are deleted and created.
347
+
348
+ :param answer: prefilled answer whether the user wants to empty Ricgraph.
349
+ 'yes': Ricgraph will be emptied, no questions asked;
350
+ 'no': Ricgraph will not be emptied, no questions asked;
351
+ any other answer: the user will be asked whether to empty Ricgraph.
352
+ :return: None.
353
+ """
354
+ global _graph
355
+
356
+ if answer == 'yes':
357
+ print('Emptying Ricgraph: Ricgraph will be emptied.\n')
358
+ # Fall through.
359
+ elif answer == 'no':
360
+ print('Emptying Ricgraph: Ricgraph will not be emptied.\n')
361
+ return
362
+ else:
363
+ print('Do you want to empty Ricgraph? You have the following options:')
364
+ print('- "yes": Ricgraph will be emptied.')
365
+ print('- "no": Ricgraph will not be emptied.')
366
+ print('- any other answer: Ricgraph will not be emptied, execution of this script will abort.')
367
+ user_input = input('Please make a choice: ')
368
+ if user_input.lower() == 'yes':
369
+ print('\nRicgraph will be emptied.\n')
370
+ # Fall through.
371
+ elif user_input.lower() == 'no':
372
+ print('\nRicgraph has not been emptied.\n')
373
+ return
374
+ else:
375
+ print('\nRicgraph has not been emptied, exiting.\n')
376
+ exit(1)
377
+
378
+ graphdb_name = ricgraph_database()
379
+ graphdb_databasename = ricgraph_databasename()
380
+
381
+ # Delete the database and start over again.
382
+ # Sometimes the following statement fails with Neo4j Desktop graph database backend
383
+ # if there are many nodes, see e.g.
384
+ # https://stackoverflow.com/questions/23310114/how-to-reset-clear-delete-neo4j-database.
385
+ # Apparently, the community edition of Neo4j does not have a
386
+ # "CREATE OR REPLACE DATABASE customers" command.
387
+ print('Deleting all nodes and edges in Ricgraph...\n')
388
+ _graph.execute_query('MATCH (node) DETACH DELETE node', database_=graphdb_databasename)
389
+
390
+ if graphdb_name == 'neo4j':
391
+ # Do not use TEXT indexes, use RANGE indexes, these are much faster during harvesting
392
+ # (but they do not support a CONTAINS substring search which is being used for a broad search
393
+ # in read_all_nodes()). The RANGE index is the default in Neo4j. More info:
394
+ # https://neo4j.com/docs/cypher-manual/current/indexes/search-performance-indexes/managing-indexes
395
+ # [Jan. 2023] The graph database backend Neo4j can have at most 3 indexes.
396
+ # [June 5, 2024] This is not true anymore. I use 4 indexes.
397
+ _graph.execute_query('DROP INDEX KeyIndex IF EXISTS', database_=graphdb_databasename)
398
+ _graph.execute_query('DROP INDEX NameIndex IF EXISTS', database_=graphdb_databasename)
399
+ _graph.execute_query('DROP INDEX CategoryIndex IF EXISTS', database_=graphdb_databasename)
400
+ _graph.execute_query('DROP INDEX ValueIndex IF EXISTS', database_=graphdb_databasename)
401
+
402
+ print('Creating indexes...')
403
+ _graph.execute_query('CREATE INDEX KeyIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node._key)',
404
+ database_=graphdb_databasename)
405
+ _graph.execute_query('CREATE INDEX NameIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node.name)',
406
+ database_=graphdb_databasename)
407
+ _graph.execute_query('CREATE INDEX CategoryIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node.category)',
408
+ database_=graphdb_databasename)
409
+ _graph.execute_query('CREATE INDEX ValueIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node.value)',
410
+ database_=graphdb_databasename)
411
+
412
+ print('These indexes have been created:')
413
+ records, summary, keys = _graph.execute_query('SHOW INDEXES',
414
+ database_=graphdb_databasename)
415
+ index_table = pandas.DataFrame(data=records, columns=keys)
416
+ print(index_table.to_string(index=False))
417
+ print('')
418
+ elif graphdb_name == 'memgraph':
419
+ # For Memgraph, we need to create indexes in the following way, otherwise we get an
420
+ # 'Index manipulation not allowed in multicommand transactions'
421
+ # error. We should use implicit (or auto-commit) transactions.
422
+ # See https://memgraph.com/docs/client-libraries/python#transaction-management.
423
+ with _graph.session(database=GRAPHDB) as session:
424
+ # These also work in case they do not exist.
425
+ session.run('DROP INDEX ON :RicgraphNode;')
426
+ session.run('DROP INDEX ON :RicgraphNode(_key);')
427
+ session.run('DROP INDEX ON :RicgraphNode(name);')
428
+ session.run('DROP INDEX ON :RicgraphNode(category);')
429
+ session.run('DROP INDEX ON :RicgraphNode(value);')
430
+
431
+ print('Creating indexes...')
432
+ session.run('CREATE INDEX ON :RicgraphNode;')
433
+ session.run('CREATE INDEX ON :RicgraphNode(_key);')
434
+ session.run('CREATE INDEX ON :RicgraphNode(name);')
435
+ session.run('CREATE INDEX ON :RicgraphNode(category);')
436
+ session.run('CREATE INDEX ON :RicgraphNode(value);')
437
+
438
+ print('These indexes have been created:')
439
+ for index_line in session.run('SHOW INDEX INFO;'):
440
+ print(index_line)
441
+ print('')
442
+
443
+ # session.close() is done automatically because of 'with'.
444
+ else:
445
+ print('empty_ricgraph(): Unknown graph database backend "' + graphdb_name + '".')
446
+ print('Exiting.')
447
+ exit(1)
448
+ return
449
+
450
+
451
+ def ricgraph_nr_nodes() -> int:
452
+ """Count the number of nodes in Ricgraph.
453
+
454
+ :return: the number of nodes, or -1 on error.
455
+ """
456
+ global _graph
457
+
458
+ if _graph is None:
459
+ print('\nricgraph_nr_nodes(): Error: graph has not been initialized or opened.\n\n')
460
+ return -1
461
+
462
+ cypher_query = 'MATCH () RETURN count(*) AS count'
463
+ result = _graph.execute_query(cypher_query,
464
+ result_transformer_=Result.data,
465
+ database_=ricgraph_databasename())
466
+ if len(result) == 0:
467
+ return -1
468
+ result = result[0]
469
+ if 'count' not in result:
470
+ return -1
471
+ result = int(result['count'])
472
+ return result
473
+
474
+
475
+ def ricgraph_nr_edges() -> int:
476
+ """Count the number of edges in Ricgraph.
477
+
478
+ :return: the number of edges, or -1 on error.
479
+ """
480
+ global _graph
481
+
482
+ if _graph is None:
483
+ print('\nricgraph_nr_edges(): Error: graph has not been initialized or opened.\n\n')
484
+ return -1
485
+
486
+ cypher_query = 'MATCH ()-[r]->() RETURN count(r) AS count'
487
+ result = _graph.execute_query(cypher_query,
488
+ result_transformer_=Result.data,
489
+ database_=ricgraph_databasename())
490
+ if len(result) == 0:
491
+ return -1
492
+ result = result[0]
493
+ if 'count' not in result:
494
+ return -1
495
+ result = int(result['count'])
496
+ return result
497
+
498
+
499
+ def ricgraph_nr_edges_of_node(node_element_id: str) -> int:
500
+ """Count the number of edges in Ricgraph of a given node.
501
+
502
+ :param node_element_id: the element_id of the node.
503
+ :return: the number of nodes, or -1 on error.
504
+ """
505
+ global _graph
506
+
507
+ if _graph is None:
508
+ print('\nricgraph_nr_edges_of_node(): Error: graph has not been initialized or opened.\n\n')
509
+ return -1
510
+
511
+ cypher_query = 'MATCH (node:RicgraphNode)-[r]->() '
512
+ if ricgraph_database() == 'neo4j':
513
+ cypher_query += 'WHERE elementId(node)=$node_element_id '
514
+ else:
515
+ cypher_query += 'WHERE id(node)=toInteger($node_element_id) '
516
+ cypher_query += 'RETURN count(r) AS count'
517
+
518
+ result = _graph.execute_query(cypher_query,
519
+ node_element_id=node_element_id,
520
+ result_transformer_=Result.data,
521
+ database_=ricgraph_databasename())
522
+ if len(result) == 0:
523
+ return -1
524
+ result = result[0]
525
+ if 'count' not in result:
526
+ return -1
527
+ result = int(result['count'])
528
+ return result
529
+
530
+
531
+ def cypher_create_node(node_properties: dict) -> Union[Node, None]:
532
+ """
533
+ Create a node in the graph database.
534
+ Create means: the node will be created, regardless if it is already
535
+ present or not. No test for presence of a node is done.
536
+ The properties and their values in 'node_properties' will be added
537
+ as properties & values of the node.
538
+ The 'name', 'category', and 'value' properties are expected to be
539
+ in 'node_properties'.
540
+
541
+ :param node_properties: the properties of the node.
542
+ :return: the node updated, or None on error.
543
+ """
544
+ global graphdb_nr_creates
545
+
546
+ # There are several methods for creating a node in the graph database.
547
+ # This would be an alternative, but it seems to use more memory than the
548
+ # one used now, according to 'PROFILE <cypher query>'.
549
+ # cypher_query = 'CREATE (node:RicgraphNode) SET node=$node_properties ' [etc.]
550
+ cypher_query = 'CREATE (node:RicgraphNode $node_properties) RETURN node'
551
+
552
+ # print('cypher_create_node(): cypher_query: ' + cypher_query)
553
+ # print(' node_properties: ' + str(node_properties))
554
+
555
+ nodes = _graph.execute_query(cypher_query,
556
+ node_properties=node_properties,
557
+ result_transformer_=Result.value,
558
+ database_=ricgraph_databasename())
559
+ graphdb_nr_creates += 1
560
+ if len(nodes) == 0:
561
+ return None
562
+ else:
563
+ return nodes[0]
564
+
565
+
566
+ def cypher_delete_node(node_element_id: str) -> None:
567
+ """Delete a node in the graph database.
568
+ If it has any edges, these will also be deleted.
569
+
570
+ :param node_element_id: the element_id of the node.
571
+ :return: None.
572
+ """
573
+ global graphdb_nr_deletes
574
+
575
+ cypher_query = 'MATCH (node:RicgraphNode) '
576
+ if ricgraph_database() == 'neo4j':
577
+ cypher_query += 'WHERE elementId(node)=$node_element_id '
578
+ else:
579
+ cypher_query += 'WHERE id(node)=toInteger($node_element_id) '
580
+ cypher_query += 'DETACH DELETE node'
581
+ _graph.execute_query(cypher_query,
582
+ node_element_id=node_element_id,
583
+ result_transformer_=Result.value,
584
+ database_=ricgraph_databasename())
585
+
586
+ nr_edges = ricgraph_nr_edges_of_node(node_element_id=node_element_id)
587
+ graphdb_nr_deletes += 1 + nr_edges # '1' for node, add all of its edges.
588
+ return
589
+
590
+
591
+ def cypher_update_node_properties(node_element_id: str, node_properties: dict) -> Union[Node, None]:
592
+ """
593
+ Update node properties in a node in the graph database.
594
+ If the node is not present, nothing will happen.
595
+ Only the properties and their values in 'node_properties' will be changed (if
596
+ a property is present in the node) or added (if the property is not present).
597
+ All other properties (i.e. not specified properties in 'node_properties')
598
+ will be left as they are.
599
+
600
+ :param node_element_id: the element_id of the node.
601
+ :param node_properties: the properties of the node.
602
+ :return: the node updated, or None on error.
603
+ """
604
+ global graphdb_nr_reads, graphdb_nr_updates
605
+
606
+ cypher_query = 'MATCH (node:RicgraphNode) '
607
+ if ricgraph_database() == 'neo4j':
608
+ cypher_query += 'WHERE elementId(node)=$node_element_id '
609
+ else:
610
+ cypher_query += 'WHERE id(node)=toInteger($node_element_id) '
611
+ cypher_query += 'SET node+=$node_properties RETURN node'
612
+
613
+ # print('cypher_update_node_properties(): node_element_id: ' + str(node_element_id) +
614
+ # ', cypher_query: ' + cypher_query)
615
+ # print(' node_properties: ' + str(node_properties))
616
+
617
+ nodes = _graph.execute_query(cypher_query,
618
+ node_element_id=node_element_id,
619
+ node_properties=node_properties,
620
+ result_transformer_=Result.value,
621
+ database_=ricgraph_databasename())
622
+ graphdb_nr_reads += 1
623
+ graphdb_nr_updates += 1
624
+ if len(nodes) == 0:
625
+ return None
626
+ else:
627
+ return nodes[0]
628
+
629
+
630
+ def cypher_merge_nodes(node_merge_from_element_id: str,
631
+ node_merge_to_element_id: str,
632
+ node_merge_to_properties: dict) -> Union[Node, None]:
633
+ """Merge two nodes in the graph database.
634
+ The neighbors of 'node_merge_from' will be merged to node 'node_merge_to'
635
+ and 'node_merge_from' will be deleted.
636
+ The node properties in 'node_merge_to' will be set according to
637
+ 'node_merge_to_properties'.
638
+
639
+ Only the properties and their values in 'node_properties' will be changed (if
640
+ a property is present in the node) or added (if the property is not present).
641
+ All other properties (i.e. not specified properties in 'node_properties')
642
+ will be left as they are.
643
+
644
+ :param node_merge_from_element_id: the element_id of node_merge_from.
645
+ :param node_merge_to_element_id: the element_id of node_merge_to.
646
+ :param node_merge_to_properties: the properties of node_merge_to.
647
+ :return: the node updated, or None on error.
648
+ """
649
+ global graphdb_nr_reads, graphdb_nr_updates
650
+
651
+ graphdb_name = ricgraph_database()
652
+
653
+ if node_merge_from_element_id == node_merge_to_element_id:
654
+ # We only update node properties.
655
+ node = cypher_update_node_properties(node_element_id=node_merge_to_element_id,
656
+ node_properties=node_merge_to_properties)
657
+ return node
658
+
659
+ cypher_query = 'MATCH (node_from:RicgraphNode) '
660
+ if graphdb_name == 'neo4j':
661
+ cypher_query += 'WHERE elementId(node_from)=$node_merge_from_element_id '
662
+ else:
663
+ cypher_query += 'WHERE id(node_from)=toInteger($node_merge_from_element_id) '
664
+ cypher_query += 'MATCH (node_to:RicgraphNode) '
665
+ if graphdb_name == 'neo4j':
666
+ cypher_query += 'WHERE elementId(node_to)=$node_merge_to_element_id '
667
+ else:
668
+ cypher_query += 'WHERE id(node_to)=toInteger($node_merge_to_element_id) '
669
+
670
+ cypher_query += 'SET node_to+=$node_merge_to_properties '
671
+ cypher_query += 'WITH node_from, node_to '
672
+ # Only test for an edge in one direction, that is sufficient.
673
+ cypher_query += 'MATCH (node_from)-[:LINKS_TO]->(neighbor_node) '
674
+ # Prevent creating a self-relationship on node_to.
675
+ cypher_query += 'WHERE node_to <> neighbor_node '
676
+ cypher_query += 'MERGE (node_to)-[:LINKS_TO]->(neighbor_node) '
677
+ cypher_query += 'MERGE (node_to)<-[:LINKS_TO]-(neighbor_node) '
678
+ cypher_query += 'DETACH DELETE node_from '
679
+ cypher_query += 'RETURN node_to'
680
+
681
+ # print('cypher_merge_nodes(): node_merge_from_element_id: ' + str(node_merge_from_element_id))
682
+ # print(' node_merge_to_element_id: ' + str(node_merge_to_element_id))
683
+ # print(' node_merge_to_properties: ' + str(node_merge_to_properties))
684
+ # print(' cypher_query: ' + cypher_query)
685
+
686
+ nodes = _graph.execute_query(cypher_query,
687
+ node_merge_from_element_id=node_merge_from_element_id,
688
+ node_merge_to_element_id=node_merge_to_element_id,
689
+ node_merge_to_properties=node_merge_to_properties,
690
+ result_transformer_=Result.value,
691
+ database_=ricgraph_databasename())
692
+
693
+ # If merge_node_from does not have neighbors, it will not be deleted.
694
+ # Do another Cypher query to be sure it is gone.
695
+ cypher_delete_node(node_element_id=node_merge_from_element_id)
696
+
697
+ nr_edges = ricgraph_nr_edges_of_node(node_element_id=node_merge_to_element_id)
698
+ graphdb_nr_reads += 2 * nr_edges # Approximation: edges are in two directions.
699
+ graphdb_nr_updates += 2 * nr_edges # Approximation: edges are in two directions.
700
+ if len(nodes) == 0:
701
+ return None
702
+ else:
703
+ return nodes[0]
704
+
705
+
706
+ def cypher_create_edge_if_not_exists(left_node_element_id: str, right_node_element_id: str) -> None:
707
+ """Create an edge between two nodes, but only if the edge does not exist.
708
+
709
+ :param left_node_element_id: the element_id of the left node.
710
+ :param right_node_element_id: the element_id of the right node.
711
+ :return: None.
712
+ """
713
+ global graphdb_nr_reads, graphdb_nr_updates
714
+
715
+ graphdb_name = ricgraph_database()
716
+
717
+ # There are several methods for getting the nodes in the graph database.
718
+ # This one takes the most time.
719
+ # It amounts to ?? database hits according to 'PROFILE <cypher query>', using 'AllNodesScan'.
720
+ # cypher_query = 'MATCH (left_node {ID:' + str(left_node.element_id) + '}) ' [etc.]
721
+ # The next one takes average time.
722
+ # It amounts to 23 database hits according to 'PROFILE', using 'AllNodesScan'.
723
+ # cypher_query = 'MATCH (left_node) WHERE left_node._key="' + str(left_node['_key']) + '" ' [etc.]
724
+ # The next one is the fastest.
725
+ # It amounts to 1 database hits according to 'PROFILE' using 'NodeByIdSeek'.
726
+ cypher_query = 'MATCH (left_node:RicgraphNode) '
727
+ if graphdb_name == 'neo4j':
728
+ cypher_query += 'WHERE elementId(left_node)=$left_node_element_id '
729
+ else:
730
+ cypher_query += 'WHERE id(left_node)=toInteger($left_node_element_id) '
731
+ cypher_query += 'MATCH (right_node:RicgraphNode) '
732
+ if graphdb_name == 'neo4j':
733
+ cypher_query += 'WHERE elementId(right_node)=$right_node_element_id '
734
+ else:
735
+ cypher_query += 'WHERE id(right_node)=toInteger($right_node_element_id) '
736
+
737
+ # We could use 'CREATE', but then we may get multiple edges for the same direction.
738
+ # cypher_query += 'CREATE (left_node)-[:LINKS_TO]->(right_node), ' [etc.]
739
+ # A MERGE is more expensive since it first checks for presence of the edge.
740
+ cypher_query += 'MERGE (left_node)-[:LINKS_TO]->(right_node) '
741
+ cypher_query += 'MERGE (left_node)<-[:LINKS_TO]-(right_node) '
742
+
743
+ # print('cypher_create_edge_if_not_exists(): left_node_id: ' + str(left_node_id) + ', right_node_id: '
744
+ # + str(right_node_id) + ', cypher_query: ' + cypher_query)
745
+
746
+ _graph.execute_query(cypher_query,
747
+ left_node_element_id=left_node_element_id,
748
+ right_node_element_id=right_node_element_id,
749
+ database_=ricgraph_databasename())
750
+ graphdb_nr_reads += 2 # one for left_node, one for right_node
751
+ graphdb_nr_updates += 2 # one for ->, one for <-.
752
+ return
753
+
754
+
755
+ # ##############################################################################
756
+ # General Ricgraph functions.
757
+ # ##############################################################################
758
+ def get_ricgraph_ini_file() -> str:
759
+ """Get the location of the ricgraph ini file.
760
+
761
+ :return: the location of the ini file.
762
+ """
763
+ # Try to find RICGRAPH_INI_FILENAME in the root of the virtual environment.
764
+ ricgraph_ini_path = sys.prefix
765
+ ricgraph_ini = os.path.join(ricgraph_ini_path, RICGRAPH_INI_FILENAME)
766
+ if os.path.exists(ricgraph_ini):
767
+ return ricgraph_ini
768
+
769
+ # Try to find RICGRAPH_INI_FILENAME in the parent directory of the venv,
770
+ # which may happen when using a Python IDE.
771
+ ricgraph_ini_path_parent = os.path.dirname(ricgraph_ini_path)
772
+ ricgraph_ini = os.path.join(ricgraph_ini_path_parent, RICGRAPH_INI_FILENAME)
773
+ if os.path.exists(ricgraph_ini):
774
+ return ricgraph_ini
775
+
776
+ print('Ricgraph initialization: error, Ricgraph ini file "' + RICGRAPH_INI_FILENAME + '" not found in')
777
+ print(' directory "' + ricgraph_ini_path + '", nor in')
778
+ print(' directory "' + ricgraph_ini_path_parent + '", exiting.')
779
+ exit(1)
780
+
781
+
782
+ def timestamp(seconds: bool = False) -> str:
783
+ """Get a timestamp only consisting of a time.
784
+
785
+ :param seconds: If True, also show seconds in the timestamp.
786
+ :return: the timestamp.
787
+ """
788
+ now = datetime.now()
789
+ if seconds:
790
+ time_stamp = now.strftime("%H:%M:%S")
791
+ else:
792
+ time_stamp = now.strftime("%H:%M")
793
+ return time_stamp
794
+
795
+
796
+ def datetimestamp(seconds: bool = False) -> str:
797
+ """Get a timestamp consisting of a date and a time.
798
+
799
+ :param seconds: If True, also show seconds in the timestamp.
800
+ :return: the timestamp.
801
+ """
802
+ now = datetime.now()
803
+ if seconds:
804
+ datetime_stamp = now.strftime("%Y-%m-%d %H:%M:%S")
805
+ else:
806
+ datetime_stamp = now.strftime("%Y-%m-%d %H:%M")
807
+ return datetime_stamp
808
+
809
+
810
+ def graphdb_nr_accesses_reset() -> None:
811
+ """Reset the counters that are used to count the number of accesses
812
+ to the graph database backend.
813
+
814
+ :return: None.
815
+ """
816
+ global graphdb_nr_creates, graphdb_nr_reads
817
+ global graphdb_nr_updates, graphdb_nr_deletes
818
+
819
+ graphdb_nr_creates = 0
820
+ graphdb_nr_reads = 0
821
+ graphdb_nr_updates = 0
822
+ graphdb_nr_deletes = 0
823
+ return
824
+
825
+
826
+ def graphdb_nr_accesses_print() -> None:
827
+ """Print the values of the counters for the number of accesses to
828
+ the graph database backend.
829
+
830
+ :return: None.
831
+ """
832
+ # These counters are used to count the number of accesses to the graph database.
833
+ global graphdb_nr_creates, graphdb_nr_reads, graphdb_nr_updates, graphdb_nr_deletes
834
+
835
+ graphdb_total_accesses = graphdb_nr_creates + graphdb_nr_reads
836
+ graphdb_total_accesses += (2 * graphdb_nr_updates) + graphdb_nr_deletes
837
+ nr_nodes = ricgraph_nr_nodes()
838
+ nr_edges = ricgraph_nr_edges()
839
+
840
+ print('\n')
841
+ print('These are the number of accesses to the graph database backend at ' + datetimestamp() + ':')
842
+ print('- total number of creates: {:>10}'.format(graphdb_nr_creates))
843
+ print('- total number of reads: {:>10}'.format(graphdb_nr_reads))
844
+ print('- total number of updates: {:>10}'.format(graphdb_nr_updates))
845
+ print('- total number of deletes: {:>10}'.format(graphdb_nr_deletes))
846
+ print('- total number of accesses: {:>10}'.format(graphdb_total_accesses))
847
+ print('Please note that for the total number of accesses, an "update" counts twice: '
848
+ + 'one for the read and one for the update of the value.')
849
+ print('\n')
850
+ print('These are the number of nodes and edges in Ricgraph:')
851
+ print('- total number of nodes: {:>8}'.format(nr_nodes))
852
+ print('- total number of edges: {:>8}'.format(nr_edges))
853
+ print('\n')
854
+ return
855
+
856
+
857
+ def create_ricgraph_key(name: str, value: str) -> str:
858
+ """Create a key for a node.
859
+ This function generates a composite key for a node in a graph.
860
+
861
+ :param name: name of the node.
862
+ :param value: value of the node.
863
+ :return: key generated for the node.
864
+ """
865
+ # Check and correct for occurrences of RICGRAPH_KEY_SEPARATOR.
866
+ name = name.replace(RICGRAPH_KEY_SEPARATOR, '#')
867
+ value = value.replace(RICGRAPH_KEY_SEPARATOR, '#')
868
+
869
+ return value.lower() + RICGRAPH_KEY_SEPARATOR + name.lower()
870
+
871
+
872
+ def get_namepart_from_ricgraph_key(key: str) -> str:
873
+ """Get the 'name' part from the composite key in a node.
874
+
875
+ :param key: key of the node.
876
+ :return: name part of key for the node.
877
+ """
878
+ key_list = key.split(sep=RICGRAPH_KEY_SEPARATOR)
879
+ if len(key_list) != 2:
880
+ return ''
881
+ return key_list[1]
882
+
883
+
884
+ def get_valuepart_from_ricgraph_key(key: str) -> str:
885
+ """Get the 'value' part from the composite key in a node.
886
+
887
+ :param key: key of the node.
888
+ :return: value part of key for the node.
889
+ """
890
+ key_list = key.split(sep=RICGRAPH_KEY_SEPARATOR)
891
+ if len(key_list) != 2:
892
+ return ''
893
+ return key_list[0]
894
+
895
+
896
+ def create_well_known_url(name: str, value: str) -> str:
897
+ """Create a URL to refer to the source of a 'well known' identifier (i.e. ORCID, ISNI, etc.).
898
+
899
+ :param name: an identifier name, e.g. ORCID, ISNI, etc.
900
+ :param value: the value.
901
+ :return: URL.
902
+ """
903
+ if name == '' or value == '':
904
+ return ''
905
+
906
+ if name == 'DOI':
907
+ return 'https://doi.org/' + value
908
+ elif name == 'GITHUB':
909
+ return 'https://www.github.com/' + value
910
+ elif name == 'ISNI':
911
+ return 'https://isni.org/isni/' + value
912
+ elif name == 'LINKEDIN':
913
+ return 'https://www.linkedin.com/in/' + value
914
+ # elif name == 'OPENALEX':
915
+ # return 'https://openalex.org/' + value
916
+ elif name == 'ORCID':
917
+ return 'https://orcid.org/' + value
918
+ elif name == 'RESEARCHER_ID':
919
+ return 'https://www.webofscience.com/wos/author/record/' + value
920
+ elif name == 'ROR':
921
+ return 'https://ror.org/' + value
922
+ elif name == 'SCOPUS_AUTHOR_ID':
923
+ return 'https://www.scopus.com/authid/detail.uri?authorId=' + value
924
+ elif name == 'TWITTER':
925
+ return 'https://www.twitter.com/' + value
926
+ else:
927
+ return ''
928
+
929
+
930
+ def create_history_line(property_name: str, old_value: str, new_value: str) -> str:
931
+ """Create a line to be used in the 'history' property of a node.
932
+ """
933
+ return 'Updated property "' + property_name + '" from "' + old_value + '" to "' + new_value + '". '
934
+
935
+
936
+ def create_name_cache_in_personroot(node: Node, personroot: Node) -> None:
937
+ """This function caches the value of a node with 'name' 'FULL_NAME'
938
+ in the 'comment' property of its person-root node 'personroot'.
939
+ If the cache is not present, it is created.
940
+
941
+ :param node: node to add FULL_NAME to cache.
942
+ :param personroot: personroot node of 'node'. For efficiency, we do not check
943
+ if this really is the case.
944
+ :return: No return value.
945
+ """
946
+ if node is None:
947
+ return
948
+ if personroot is None:
949
+ return
950
+ if node['name'] == 'FULL_NAME':
951
+ time_stamp = datetimestamp()
952
+ if isinstance(personroot['comment'], str):
953
+ if personroot['comment'] == '':
954
+ old_value = str(personroot['comment']) # Not necessary, for symmetry.
955
+ node_properties = {'comment': [node['value']]}
956
+ history_line = create_history_line(property_name='comment',
957
+ old_value=old_value,
958
+ new_value=str(node_properties['comment']))
959
+ node_properties['_history'] = personroot['_history'].copy()
960
+ node_properties['_history'].append(time_stamp + ': ' + history_line)
961
+ cypher_update_node_properties(node_element_id=personroot.element_id,
962
+ node_properties=node_properties)
963
+ elif isinstance(personroot['comment'], list):
964
+ if node['value'] not in personroot['comment']:
965
+ old_value = str(personroot['comment'])
966
+ node_properties = {'comment': personroot['comment'].copy()}
967
+ node_properties['comment'].append(node['value'])
968
+ history_line = create_history_line(property_name='comment',
969
+ old_value=old_value,
970
+ new_value=str(node_properties['comment']))
971
+ node_properties['_history'] = personroot['_history'].copy()
972
+ node_properties['_history'].append(time_stamp + ': ' + history_line)
973
+ cypher_update_node_properties(node_element_id=personroot.element_id,
974
+ node_properties=node_properties)
975
+ # In all other cases: it is already in the cache,
976
+ # or leave it as it is: 'comment' seems to be used for something we don't know.
977
+ return
978
+
979
+
980
+ def recreate_name_cache_in_personroot(personroot: Node) -> None:
981
+ """This function recreates the cache of all the nodes with 'name' 'FULL_NAME'
982
+ in the 'comment' property of its person-root node 'personroot'.
983
+ If the cache is not present, it is created.
984
+
985
+ :param personroot: personroot node of 'node'. For efficiency, we do not check
986
+ if this really is the case.
987
+ :return: No return value.
988
+ """
989
+ if personroot is None:
990
+ return
991
+ neighbornodes = get_all_neighbor_nodes(node=personroot,
992
+ name_want='FULL_NAME')
993
+ name_cache = []
994
+ for node in neighbornodes:
995
+ if node['value'] not in name_cache:
996
+ name_cache.append(node['value'])
997
+
998
+ # We need to do this irrespective of the length of name_cache.
999
+ # If the length is 0, we might be deleting FULL_NAME nodes and that
1000
+ # should also be reflected in the name cache.
1001
+ if (isinstance(personroot['comment'], str) and personroot['comment'] == '') \
1002
+ or isinstance(personroot['comment'], list):
1003
+ time_stamp = datetimestamp()
1004
+ history_line = create_history_line(property_name='comment',
1005
+ old_value=str(personroot['comment']),
1006
+ new_value=str(name_cache))
1007
+ node_properties = {'comment': name_cache.copy(),
1008
+ '_history': personroot['_history'].copy()}
1009
+ node_properties['_history'].append(time_stamp + ': Cleaned and recreated name cache. ' + history_line)
1010
+ cypher_update_node_properties(node_element_id=personroot.element_id,
1011
+ node_properties=node_properties)
1012
+ # In all other cases: leave it as it is: 'comment' seems to be used for something we don't know.
1013
+ return
1014
+
1015
+
1016
+ def create_update_node(name: str, category: str, value: str,
1017
+ other_properties: dict = None) -> Union[Node, None]:
1018
+ """Create a node, or update its values if is already present.
1019
+ The new/updated node will have values 'name', 'category', and 'value', and
1020
+ the values in other_properties.
1021
+ For now all properties will have a string value, except for '_source'
1022
+ (a sorted list), and '_history' (a list).
1023
+ Changing the 'name' or 'value' properties (and, subsequently, the '_key' value),
1024
+ cannot be done with this function, since in that case we would not be able
1025
+ to find it.
1026
+
1027
+ :param name: 'name' property of node to create or update.
1028
+ :param category: 'category' property of node.
1029
+ :param value: 'value' property of node.
1030
+ :param other_properties: a dictionary of all the other properties.
1031
+ :return: the node created, or None if this was not possible
1032
+ """
1033
+ if other_properties is None:
1034
+ other_properties = {}
1035
+ global _graph
1036
+
1037
+ if _graph is None:
1038
+ print('\ncreate_update_node(): Error: graph has not been initialized or opened.\n\n')
1039
+ return None
1040
+
1041
+ if not isinstance(name, str) \
1042
+ or not isinstance(category, str) \
1043
+ or not isinstance(value, str):
1044
+ return None
1045
+
1046
+ if other_properties is None:
1047
+ other_properties = {}
1048
+
1049
+ lname = str(name)
1050
+ lcategory = str(category)
1051
+ lvalue = str(value)
1052
+
1053
+ if lname == '' or lcategory == '' or lvalue == '':
1054
+ return None
1055
+
1056
+ node_properties = {}
1057
+ time_stamp = datetimestamp()
1058
+ node = read_node(name=lname, value=lvalue)
1059
+ if node is None:
1060
+ # Create a node.
1061
+ # First do the properties in RICGRAPH_PROPERTIES_STANDARD.
1062
+ node_properties['name'] = lname
1063
+ node_properties['category'] = lcategory
1064
+ node_properties['value'] = lvalue
1065
+
1066
+ # Then do the properties in RICGRAPH_PROPERTIES_ADDITIONAL.
1067
+ for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
1068
+ if prop_name in other_properties:
1069
+ node_properties[prop_name] = str(other_properties[prop_name])
1070
+ else:
1071
+ node_properties[prop_name] = ''
1072
+
1073
+ # If no url_main has been passed, insert an ISNI, DOI, etc. url if category is ISNI, DOI, etc.
1074
+ url = create_well_known_url(name=lname, value=lvalue)
1075
+ if node_properties['url_main'] == '' and url != '':
1076
+ node_properties['url_main'] = url
1077
+
1078
+ # Then do the properties in RICGRAPH_PROPERTIES_HIDDEN.
1079
+ node_properties['_key'] = create_ricgraph_key(name=lname, value=lvalue)
1080
+ if node_properties['source_event'] == '':
1081
+ node_properties['_source'] = []
1082
+ else:
1083
+ node_properties['_source'] = [node_properties['source_event']]
1084
+ node_properties['source_event'] = ''
1085
+
1086
+ if node_properties['history_event'] == '':
1087
+ node_properties['_history'] = [time_stamp + ': Created. ']
1088
+ else:
1089
+ node_properties['_history'] = [time_stamp + ': Created. ' + node_properties['history_event']]
1090
+ node_properties['history_event'] = ''
1091
+
1092
+ new_node = cypher_create_node(node_properties=node_properties)
1093
+ return new_node
1094
+
1095
+ if RICGRAPH_NODEADD_MODE == 'strict' and node['name'] == 'FULL_NAME':
1096
+ # We only get here if we want to connect some other node A to this FULL_NAME node B.
1097
+ # This FULL_NAME node B already exists. Most probably it is connected to a person-root C
1098
+ # and that person-root C to some other nodes D and E.
1099
+ # If we would continue, A would be connected to B, and also to C, D and E.
1100
+ # However, connecting based on a name is not a very good idea, since two
1101
+ # different persons may have the same name.
1102
+ # So don't do this if we are in NODEADD_MODE 'strict'.
1103
+ return None
1104
+
1105
+ # Update a node.
1106
+ history_line = ''
1107
+
1108
+ # First do the properties in RICGRAPH_PROPERTIES_STANDARD.
1109
+ # It is not possible to update 'name' or 'value'.
1110
+ if node['category'] != lcategory:
1111
+ history_line += create_history_line(property_name='category',
1112
+ old_value=node['category'],
1113
+ new_value=lcategory)
1114
+ node_properties['category'] = lcategory
1115
+
1116
+ # Then do the properties in RICGRAPH_PROPERTIES_ADDITIONAL.
1117
+ for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
1118
+ if prop_name not in other_properties:
1119
+ continue
1120
+ if prop_name == 'history_event' or prop_name == 'source_event':
1121
+ # Do not add these to node_properties, and don't add them to _history.
1122
+ continue
1123
+ # Only in case a property is in other_properties, its value may change.
1124
+ present_val = str(node[prop_name])
1125
+ if present_val != str(other_properties[prop_name]):
1126
+ # But its value is only changed if it is different then the old value.
1127
+ node_properties[prop_name] = str(other_properties[prop_name])
1128
+ history_line += create_history_line(property_name=prop_name,
1129
+ old_value=present_val,
1130
+ new_value=str(other_properties[prop_name]))
1131
+
1132
+ # Finally do the properties in RICGRAPH_PROPERTIES_HIDDEN.
1133
+ # It is not possible to update '_key'.
1134
+ if 'source_event' in other_properties:
1135
+ if other_properties['source_event'] != '':
1136
+ if other_properties['source_event'] not in node['_source']:
1137
+ node_properties['_source'] = node['_source'].copy()
1138
+ node_properties['_source'].append(other_properties['source_event'])
1139
+ node_properties['_source'].sort()
1140
+ history_line += create_history_line(property_name='_source',
1141
+ old_value=str(node['_source']),
1142
+ new_value=str(node_properties['_source']))
1143
+
1144
+ if history_line == '':
1145
+ # No changes.
1146
+ return node
1147
+
1148
+ node_properties['_history'] = node['_history'].copy()
1149
+ node_properties['_history'].append(time_stamp + ': Updated. ' + history_line)
1150
+ updated_node = cypher_update_node_properties(node_element_id=node.element_id,
1151
+ node_properties=node_properties)
1152
+ return updated_node
1153
+
1154
+
1155
+ def read_node(name: str = '', value: str = '') -> Union[Node, None]:
1156
+ """Read a node based on name and value.
1157
+ Since all nodes are supposed to be unique if both are
1158
+ specified, return the first one found.
1159
+
1160
+ :param name: 'name' property of node.
1161
+ :param value: 'value' property of node.
1162
+ :return: the node read, or None if no node was found.
1163
+ """
1164
+ global _graph
1165
+ global nodes_cache_key_element_id
1166
+ global graphdb_nr_reads
1167
+
1168
+ if _graph is None:
1169
+ print('\nread_node(): Error: graph has not been initialized or opened.\n\n')
1170
+ return None
1171
+
1172
+ if not isinstance(name, str) or not isinstance(value, str):
1173
+ return None
1174
+
1175
+ if name == '' or value == '':
1176
+ return None
1177
+
1178
+ graphdb_nr_reads += 1
1179
+ key = create_ricgraph_key(name=name, value=value)
1180
+ if key in nodes_cache_key_element_id:
1181
+ node_element_id = nodes_cache_key_element_id[key]
1182
+ cypher_query = 'MATCH (node:RicgraphNode) WHERE '
1183
+ if ricgraph_database() == 'neo4j':
1184
+ cypher_query += 'elementId(node)=$node_element_id '
1185
+ else:
1186
+ cypher_query += 'id(node)=toInteger($node_element_id) '
1187
+ cypher_query += 'RETURN node'
1188
+ nodes = _graph.execute_query(cypher_query,
1189
+ node_element_id=node_element_id,
1190
+ result_transformer_=Result.value,
1191
+ database_=ricgraph_databasename())
1192
+ if len(nodes) == 0:
1193
+ return None
1194
+ else:
1195
+ return nodes[0]
1196
+ else:
1197
+ if len(nodes_cache_key_element_id) > MAX_NODES_CACHE_KEY_ID:
1198
+ # Empty cache.
1199
+ nodes_cache_key_element_id = {}
1200
+
1201
+ cypher_query = 'MATCH (node:RicgraphNode) WHERE (node.name=$node_name) AND (node.value=$node_value) '
1202
+ cypher_query += 'RETURN node'
1203
+ nodes = _graph.execute_query(cypher_query,
1204
+ node_name=name,
1205
+ node_value=value,
1206
+ result_transformer_=Result.value,
1207
+ database_=ricgraph_databasename())
1208
+ if len(nodes) == 0:
1209
+ return None
1210
+ else:
1211
+ node = nodes[0]
1212
+ nodes_cache_key_element_id[key] = node.element_id
1213
+ return node
1214
+
1215
+
1216
+ def read_all_nodes(name: str = '', category: str = '', value: str = '',
1217
+ key: str = '',
1218
+ name_is_exact_match: bool = True,
1219
+ value_is_exact_match: bool = True,
1220
+ max_nr_nodes: int = 0) -> list:
1221
+ """Read a number of nodes based on name, category or value.
1222
+ Any of these parameters can be specified.
1223
+ It is also possible to read a number of nodes based on key.
1224
+ But not name, category, value, and key at the same time.
1225
+ If key has been specified, the values of name_is_exact_match and
1226
+ value_is_exact_match are not used.
1227
+
1228
+ :param name: 'name' property of node.
1229
+ :param category: idem.
1230
+ :param value: idem.
1231
+ :param key: idem.
1232
+ :param name_is_exact_match: if True, then do an exact match search
1233
+ on field 'name', if False, then do a case-insensitive match.
1234
+ Note that a case-insensitive match is more expensive.
1235
+ :param value_is_exact_match: if True, then do an exact match search
1236
+ on field 'value', if False, then do a case-insensitive match.
1237
+ Note that a case-insensitive match is more expensive.
1238
+ :param max_nr_nodes: return at most this number of nodes, 0 = all nodes.
1239
+ :return: list of nodes read, or empty list if nothing found.
1240
+ """
1241
+ global _graph
1242
+ global graphdb_nr_reads
1243
+
1244
+ if _graph is None:
1245
+ print('\nread_all_nodes(): Error: graph has not been initialized or opened.\n\n')
1246
+ return []
1247
+
1248
+ if not isinstance(name, str) \
1249
+ or not isinstance(category, str) \
1250
+ or not isinstance(value, str) \
1251
+ or not isinstance(key, str):
1252
+ return []
1253
+
1254
+ # Special case, for speed.
1255
+ if value_is_exact_match and name != '' and category == '' and value != '':
1256
+ # Ignore 'max_nr_nodes'.
1257
+ cypher_query = 'MATCH (node:RicgraphNode) WHERE (node.name=$node_name) AND (node.value=$node_value) '
1258
+ cypher_query += 'RETURN node'
1259
+ nodes = _graph.execute_query(cypher_query,
1260
+ node_name=name,
1261
+ node_value=value,
1262
+ result_transformer_=Result.value,
1263
+ database_=ricgraph_databasename())
1264
+ nr_nodes = len(nodes)
1265
+ # Unsure what to count here, this seems reasonable. '+ 1' for first node.
1266
+ graphdb_nr_reads += nr_nodes + 1
1267
+ if nr_nodes == 0:
1268
+ return []
1269
+ else:
1270
+ return nodes
1271
+
1272
+ # Now all other cases.
1273
+ # Don't allow a search for everything.
1274
+ if name == '' and category == '' and value == '' and key == '':
1275
+ return []
1276
+
1277
+ if key != '' and name == '' and category == '' and value == '':
1278
+ # Ignore 'max_nr_nodes'.
1279
+ cypher_query = 'MATCH (node:RicgraphNode) WHERE (node._key=$node_key) '
1280
+ cypher_query += 'RETURN node'
1281
+ nodes = _graph.execute_query(cypher_query,
1282
+ node_key=key,
1283
+ result_transformer_=Result.value,
1284
+ database_=ricgraph_databasename())
1285
+ nr_nodes = len(nodes)
1286
+ # Unsure what to count here, this seems reasonable. '+ 1' for first node.
1287
+ graphdb_nr_reads += nr_nodes + 1
1288
+ if nr_nodes == 0:
1289
+ return []
1290
+ else:
1291
+ return nodes
1292
+
1293
+ cypher_query = 'MATCH (node:RicgraphNode) WHERE '
1294
+ if name != '':
1295
+ if name_is_exact_match:
1296
+ cypher_query += '(node.name="' + name + '") AND '
1297
+ else:
1298
+ cypher_query += '(toLower(node.name) CONTAINS toLower("' + name + '")) AND '
1299
+ if category != '':
1300
+ cypher_query += '(node.category="' + category + '") AND '
1301
+ if value != '':
1302
+ # Value may contain special characters.
1303
+ if value_is_exact_match:
1304
+ # Exact match search.
1305
+ cypher_query += '(node.value=$node_value) AND '
1306
+ else:
1307
+ # Case-insensitive search.
1308
+ cypher_query += '(toLower(node.value) CONTAINS toLower($node_value)) AND '
1309
+
1310
+ # Remove last 'AND ', is of length -4.
1311
+ cypher_query = cypher_query[:-4]
1312
+ cypher_query += 'RETURN node '
1313
+
1314
+ if max_nr_nodes > 0:
1315
+ cypher_query += 'LIMIT ' + str(max_nr_nodes)
1316
+ # print(cypher_query)
1317
+
1318
+ if value != '':
1319
+ # Value may contain special characters.
1320
+ nodes = _graph.execute_query(cypher_query,
1321
+ node_value=value,
1322
+ result_transformer_=Result.value,
1323
+ database_=ricgraph_databasename())
1324
+ else:
1325
+ nodes = _graph.execute_query(cypher_query,
1326
+ result_transformer_=Result.value,
1327
+ database_=ricgraph_databasename())
1328
+
1329
+ nr_nodes = len(nodes)
1330
+ # Unsure what to count here, this seems reasonable. '+ 1' for first node.
1331
+ graphdb_nr_reads += nr_nodes + 1
1332
+ if nr_nodes == 0:
1333
+ return []
1334
+ else:
1335
+ return nodes
1336
+
1337
+
1338
+ def read_all_values_of_property(node_property: str = '') -> list:
1339
+ """Read all the values of a certain property.
1340
+
1341
+ :param node_property: the property to find all values:
1342
+ 'name': all different name properties.
1343
+ 'category': all different category properties.
1344
+ '_source': all different _source properties.
1345
+ 'name_personal': all different name properties that are personal identifiers.
1346
+ :return: a sorted list with all the values, or empty list on error.
1347
+ """
1348
+ global _graph
1349
+
1350
+ if _graph is None:
1351
+ print('\nread_all_values_of_property(): Error: graph has not been initialized or opened.')
1352
+ return []
1353
+
1354
+ if ricgraph_nr_nodes() == 0:
1355
+ print('\nread_all_values_of_property(): Warning: graph is empty.')
1356
+ return []
1357
+
1358
+ if node_property != 'name' \
1359
+ and node_property != 'name_personal' \
1360
+ and node_property != 'category' \
1361
+ and node_property != '_source':
1362
+ print('\nread_all_values_of_property(): Error: function does not work for property "'
1363
+ + node_property + '".\n\n')
1364
+ return []
1365
+
1366
+ if node_property == 'name_personal':
1367
+ # Note that the comment property of 'person-root' contains FULL_NAMEs,
1368
+ # so it is also a name property that is a personal identifier.
1369
+ cypher_query = 'MATCH (node:RicgraphNode) '
1370
+ cypher_query += 'WHERE node.category = "person" '
1371
+ cypher_query += 'RETURN COLLECT (DISTINCT node.name) AS entries'
1372
+ else:
1373
+ cypher_query = 'MATCH (node:RicgraphNode) '
1374
+ cypher_query += 'RETURN COLLECT (DISTINCT node.' + node_property + ') AS entries'
1375
+ result = _graph.execute_query(cypher_query,
1376
+ result_transformer_=Result.data,
1377
+ database_=ricgraph_databasename())
1378
+ if len(result) == 0:
1379
+ return []
1380
+ result = result[0]
1381
+ if 'entries' not in result:
1382
+ return []
1383
+ result_list = result['entries']
1384
+ if len(result_list) == 0:
1385
+ return []
1386
+ if node_property == '_source':
1387
+ # This is a special case, we have a list of lists that we need to untangle.
1388
+ list_of_lists = result_list
1389
+ result_list = []
1390
+ for item in list_of_lists:
1391
+ if len(item) == 0:
1392
+ continue
1393
+ for encapsulated_item in item:
1394
+ if encapsulated_item not in result_list:
1395
+ result_list.append(encapsulated_item)
1396
+
1397
+ # We need 'key' to do a case insenstive search.
1398
+ result_list_sorted = sorted(result_list, key=str.lower)
1399
+ return result_list_sorted
1400
+
1401
+
1402
+ def update_node_value(name: str, old_value: str, new_value: str) -> Union[Node, None]:
1403
+ """Update a node, change the value property.
1404
+ This is a special case because we change the key.
1405
+ Use carefully, because other property that contain 'old_value' (such
1406
+ as 'url_main' or 'url_other') are not being updated, so they will
1407
+ point to the wrong URL.
1408
+
1409
+ :param name: 'name' property of node.
1410
+ :param old_value: old 'value' property of node.
1411
+ :param new_value: old 'value' property of node.
1412
+ :return: the node updated, or None if this was not possible.
1413
+ """
1414
+ # A lot of the code below is copied from create_update_node().
1415
+ global _graph
1416
+
1417
+ if _graph is None:
1418
+ print('\nupdate_node_value(): Error: graph has not been initialized or opened.\n\n')
1419
+ return None
1420
+
1421
+ if not isinstance(name, str) \
1422
+ or not isinstance(old_value, str) \
1423
+ or not isinstance(new_value, str):
1424
+ return None
1425
+
1426
+ lname = str(name)
1427
+ loldvalue = str(old_value)
1428
+ lnewvalue = str(new_value)
1429
+
1430
+ if lname == '' or loldvalue == '' or lnewvalue == '':
1431
+ return None
1432
+
1433
+ time_stamp = datetimestamp()
1434
+ node = read_node(name=lname, value=loldvalue)
1435
+ if node is None:
1436
+ # Node we want to change does not exist.
1437
+ return None
1438
+
1439
+ newnode = read_node(name=lname, value=lnewvalue)
1440
+ if newnode is not None:
1441
+ # The node we want to change to does already exist.
1442
+ # So this happens to be a merge of two nodes.
1443
+ merged_node = merge_two_nodes(node_merge_from=node,
1444
+ node_merge_to=newnode)
1445
+ return merged_node
1446
+
1447
+ node_properties = {'value': lnewvalue}
1448
+ oldkey = node['_key']
1449
+ newkey = create_ricgraph_key(name=lname, value=lnewvalue)
1450
+ node_properties['_key'] = newkey
1451
+ history_line = create_history_line(property_name='value', old_value=loldvalue, new_value=lnewvalue)
1452
+ history_line += create_history_line(property_name='_key', old_value=oldkey, new_value=newkey)
1453
+ node_properties['_history'] = node['_history'].copy()
1454
+ node_properties['_history'].append(time_stamp + ': Updated. ' + history_line)
1455
+ updated_node = cypher_update_node_properties(node_element_id=node.element_id,
1456
+ node_properties=node_properties)
1457
+
1458
+ if updated_node['name'] == 'FULL_NAME':
1459
+ personroot = get_or_create_personroot_node(person_node=updated_node)
1460
+ recreate_name_cache_in_personroot(personroot=personroot)
1461
+ return node
1462
+
1463
+
1464
+ # Note the similarity with create_nodepairs_and_edges_df().
1465
+ def update_nodes_df(nodes: pandas.DataFrame) -> None:
1466
+ """Update the values in a node, but only if values have been changed.
1467
+ This is done for all the nodes passed in the DataFrame.
1468
+
1469
+ The column names specify the property in a node (i.e. 'name', 'category', 'value').
1470
+ The row value of that column specify the property value of that property.
1471
+
1472
+ :param nodes: the nodes in a DataFrame.
1473
+ :return: None.
1474
+ """
1475
+ nodes_clean = nodes.copy(deep=True)
1476
+ # Ensure that all '' values are NaN, so that those rows can be easily removed with dropna()
1477
+ nodes_clean.replace('', numpy.nan, inplace=True)
1478
+ nodes_clean.dropna(axis=0, how='any', inplace=True)
1479
+ nodes_clean.drop_duplicates(keep='first', inplace=True, ignore_index=True)
1480
+
1481
+ print('There are ' + str(len(nodes_clean)) + ' nodes ('
1482
+ + timestamp() + '), updating node: 0 ', end='')
1483
+ count = 0
1484
+ columns = nodes_clean.columns
1485
+ for row in nodes_clean.itertuples():
1486
+ count += 1
1487
+ if count % 250 == 0:
1488
+ print(count, ' ', end='', flush=True)
1489
+ if count % 2500 == 0:
1490
+ print('(' + timestamp() + ')\n', end='', flush=True)
1491
+
1492
+ node_properties = {}
1493
+ for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
1494
+ for other_name in columns:
1495
+ if prop_name == other_name:
1496
+ node_properties[prop_name] = getattr(row, other_name)
1497
+
1498
+ create_update_node(name=row.name, category=row.category, value=row.value,
1499
+ other_properties=node_properties)
1500
+
1501
+ print(count, '(' + timestamp() + ')\n', end='', flush=True)
1502
+ return
1503
+
1504
+
1505
+ def delete_node(node: Node) -> None:
1506
+ """Delete a node in the graph database.
1507
+ If it has any edges, these will also be deleted.
1508
+
1509
+ :param node: the node to delete.
1510
+ :return: None.
1511
+ """
1512
+ global _graph
1513
+
1514
+ if _graph is None:
1515
+ print('\ndelete_node(): Error: graph has not been initialized or opened.\n\n')
1516
+ return
1517
+
1518
+ if node is None:
1519
+ print('delete_node(): Error: node is None - cannot be found.')
1520
+ return
1521
+
1522
+ if node['name'] == 'FULL_NAME':
1523
+ # We need to recreate the name cache in the person-root node.
1524
+ personroot = get_personroot_node(node=node)
1525
+ else:
1526
+ personroot = None
1527
+
1528
+ cypher_delete_node(node_element_id=node.element_id)
1529
+
1530
+ if personroot is not None:
1531
+ recreate_name_cache_in_personroot(personroot=personroot)
1532
+
1533
+ return
1534
+
1535
+
1536
+ def get_or_create_personroot_node(person_node: Node) -> Union[Node, None]:
1537
+ """Get a 'person-root' node for a given 'person' node, if that node has
1538
+ a 'person-root'. If not, create the 'person-root' node.
1539
+
1540
+ :param person_node: the node.
1541
+ :return: the 'person-root' node, or None on error.
1542
+ """
1543
+ global _graph
1544
+
1545
+ if person_node is None:
1546
+ print('get_or_create_personroot_node(): Error: node is None.')
1547
+ return
1548
+
1549
+ if person_node['category'] != 'person':
1550
+ print('get_or_create_personroot_node(): node category is not person.')
1551
+ return None
1552
+
1553
+ if person_node['name'] == 'person-root':
1554
+ return person_node
1555
+
1556
+ personroot_nodes = get_all_personroot_nodes(node=person_node)
1557
+ if len(personroot_nodes) == 0:
1558
+ # Create the 'person-root' node with a unique value.
1559
+ value = str(uuid.uuid4())
1560
+ personroot = create_update_node(name='person-root', category='person', value=value)
1561
+ cypher_create_edge_if_not_exists(left_node_element_id=person_node.element_id,
1562
+ right_node_element_id=personroot.element_id)
1563
+ return personroot
1564
+
1565
+ if len(personroot_nodes) > 1:
1566
+ print('get_or_create_personroot_node(): not anticipated: person_node "'
1567
+ + person_node['_key'] + '" has more than one person-root nodes: ')
1568
+ for node in personroot_nodes:
1569
+ print('"' + node['_key'] + '" ', end='')
1570
+ print('\nReturn the first.')
1571
+ # And fall through.
1572
+
1573
+ personroot = personroot_nodes[0]
1574
+ return personroot
1575
+
1576
+
1577
+ def connect_person_and_non_person_node(person_node: Node,
1578
+ non_person_node: Node) -> None:
1579
+ """Connect a 'person' and a non 'person' node.
1580
+
1581
+ :param person_node: the left node, being of category 'person'.
1582
+ :param non_person_node: the right node, being of a different category than 'person'.
1583
+ :return: None.
1584
+ """
1585
+ global _graph
1586
+
1587
+ if person_node is None or non_person_node is None:
1588
+ print('connect_persons_and_non_person_node(): Error: (one of the) nodes is None.')
1589
+ return
1590
+
1591
+ if person_node['category'] != 'person' or non_person_node['category'] == 'person':
1592
+ print('connect_person_and_non_person_node(): (one of the) nodes have wrong category.')
1593
+ return
1594
+
1595
+ if person_node['name'] == 'person-root':
1596
+ personroot = person_node
1597
+ else:
1598
+ personroot = get_or_create_personroot_node(person_node=person_node)
1599
+ if personroot is None:
1600
+ return
1601
+
1602
+ cypher_create_edge_if_not_exists(left_node_element_id=non_person_node.element_id,
1603
+ right_node_element_id=personroot.element_id)
1604
+ return
1605
+
1606
+
1607
+ def merge_two_nodes(node_merge_from: Node, node_merge_to: Node) -> Union[Node, None]:
1608
+ """Merge two nodes.
1609
+ The neighbors of 'node_merge_from' will be merged to node 'node_merge_to'
1610
+ and 'node_merge_from' will be deleted.
1611
+
1612
+ :param node_merge_from: the node to merge from.
1613
+ :param node_merge_to: the node to merge to.
1614
+ :return: the merged node, or None if this was not possible.
1615
+ """
1616
+ global _graph
1617
+
1618
+ if _graph is None:
1619
+ print('\nmerge_two_nodes(): Error: graph has not been initialized or opened.\n\n')
1620
+ return None
1621
+
1622
+ if node_merge_from is None and node_merge_to is not None:
1623
+ # Done.
1624
+ return node_merge_to
1625
+
1626
+ if node_merge_from is None or node_merge_to is None:
1627
+ print('merge_two_nodes(): Error: one or both of the nodes is None - cannot be found.')
1628
+ return None
1629
+
1630
+ if node_merge_from['name'] != node_merge_to['name'] \
1631
+ or node_merge_from['category'] != node_merge_to['category']:
1632
+ print('merge_two_nodes(): nodes "' + node_merge_from['_key']
1633
+ + '" and "' + node_merge_to['_key']
1634
+ + '" have a different "name" or "category" field: not supported.')
1635
+ return None
1636
+
1637
+ if node_merge_from == node_merge_to:
1638
+ # They are the same, done.
1639
+ return node_merge_to
1640
+
1641
+ node_merge_to_properties = {'_history': node_merge_to['_history'].copy()}
1642
+ node_merge_to_properties['_source'] = list(set(node_merge_from['_source'] + node_merge_to['_source']))
1643
+ node_merge_to_properties['_source'].sort()
1644
+
1645
+ time_stamp = datetimestamp(seconds=True)
1646
+ count = 0
1647
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1648
+ what_happened += 'Merged node "'
1649
+ what_happened += node_merge_from['_key'] + '" to this node '
1650
+ what_happened += 'and then deleted it.'
1651
+ node_merge_to_properties['_history'].append(what_happened)
1652
+
1653
+ count += 1
1654
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1655
+ what_happened += '----- Start of deleted node history and neighbors -----'
1656
+ node_merge_to_properties['_history'].append(what_happened)
1657
+
1658
+ count += 1
1659
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1660
+ what_happened += 'Start of history of the deleted node.'
1661
+ node_merge_to_properties['_history'].append(what_happened)
1662
+
1663
+ for history in node_merge_from['_history']:
1664
+ count += 1
1665
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1666
+ what_happened += history
1667
+ node_merge_to_properties['_history'].append(what_happened)
1668
+
1669
+ count += 1
1670
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1671
+ what_happened += 'End of history of the deleted node.'
1672
+ node_merge_to_properties['_history'].append(what_happened)
1673
+
1674
+ count += 1
1675
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1676
+ what_happened += 'These were the neighbors of the deleted node, '
1677
+ what_happened += 'now merged with the neighbors of this node:'
1678
+ node_merge_to_properties['_history'].append(what_happened)
1679
+
1680
+ neighbornodes = get_all_neighbor_nodes(node=node_merge_from)
1681
+ if len(neighbornodes) == 0:
1682
+ count += 1
1683
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1684
+ what_happened += '[the node to be deleted has no neighbors]'
1685
+ node_merge_to_properties['_history'].append(what_happened)
1686
+ else:
1687
+ for node in neighbornodes:
1688
+ count += 1
1689
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1690
+ what_happened += '"' + str(node['_key']) + '" '
1691
+ node_merge_to_properties['_history'].append(what_happened)
1692
+
1693
+ count += 1
1694
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1695
+ what_happened += 'End of list of neighbors of the deleted node.'
1696
+ node_merge_to_properties['_history'].append(what_happened)
1697
+
1698
+ count += 1
1699
+ what_happened = time_stamp + '-' + format(count, '02d') + ': '
1700
+ what_happened += '----- End of deleted node history and neighbors -----'
1701
+ node_merge_to_properties['_history'].append(what_happened)
1702
+
1703
+ # Note only properties '_history' and '_source' are changed.
1704
+ merged_node = cypher_merge_nodes(node_merge_from_element_id=node_merge_from.element_id,
1705
+ node_merge_to_element_id=node_merge_to.element_id,
1706
+ node_merge_to_properties=node_merge_to_properties)
1707
+ if merged_node is None:
1708
+ return None
1709
+ if merged_node['name'] == 'person-root':
1710
+ # Property 'comment' is also updated.
1711
+ recreate_name_cache_in_personroot(personroot=merged_node)
1712
+
1713
+ return merged_node
1714
+
1715
+
1716
+ def merge_two_nodes_name_value(node_merge_from_name: str, node_merge_from_value: str,
1717
+ node_merge_to_name: str, node_merge_to_value: str) -> None:
1718
+ """Merge two nodes, by specifying their 'name' and 'value' fields.
1719
+ The neighbors of 'node_merge_from' will be merged to node 'node_merge_to'
1720
+ and 'node_merge_from' will be deleted.
1721
+
1722
+ :param node_merge_from_name: 'name' property of node_merge_from.
1723
+ :param node_merge_from_value: 'value' property of node_merge_from.
1724
+ :param node_merge_to_name: 'name' property of node_merge_to.
1725
+ :param node_merge_to_value: 'value' property of node_merge_to.
1726
+ :return: None.
1727
+ """
1728
+ global _graph
1729
+
1730
+ if _graph is None:
1731
+ print('\nmerge_two_nodes_name_value(): Error: graph has not been initialized or opened.\n\n')
1732
+ return
1733
+
1734
+ node_merge_from = read_node(name=node_merge_from_name,
1735
+ value=node_merge_from_value)
1736
+ node_merge_to = read_node(name=node_merge_to_name,
1737
+ value=node_merge_to_value)
1738
+ merge_two_nodes(node_merge_from=node_merge_from,
1739
+ node_merge_to=node_merge_to)
1740
+ return
1741
+
1742
+
1743
+ def connect_person_and_person_node(left_node: Node, right_node: Node) -> None:
1744
+ """Connect two person nodes.
1745
+
1746
+ :param left_node: the left node.
1747
+ :param right_node: the right node.
1748
+ :return: None.
1749
+ """
1750
+ global _graph
1751
+
1752
+ if left_node is None or right_node is None:
1753
+ print('connect_person_and_person_node(): (one of the) nodes is None.')
1754
+ return
1755
+
1756
+ if left_node['category'] != 'person' or right_node['category'] != 'person':
1757
+ print('connect_person_and_person_node(): (one of the) nodes have wrong category.')
1758
+ return
1759
+
1760
+ if left_node['name'] == 'person-root' or right_node['name'] == 'person-root':
1761
+ print('connect_person_and_person_node(): not anticipated: (one of the) person_nodes '
1762
+ + 'are "person-root" nodes.')
1763
+ return
1764
+
1765
+ nr_edges_left_node = ricgraph_nr_edges_of_node(node_element_id=left_node.element_id)
1766
+ nr_edges_right_node = ricgraph_nr_edges_of_node(node_element_id=right_node.element_id)
1767
+ if nr_edges_left_node == 0 and nr_edges_right_node == 0:
1768
+ # None of the nodes have a 'person-root' node, create one and connect.
1769
+ personroot = get_or_create_personroot_node(person_node=left_node)
1770
+ if personroot is None:
1771
+ return
1772
+ cypher_create_edge_if_not_exists(left_node_element_id=right_node.element_id,
1773
+ right_node_element_id=personroot.element_id)
1774
+ return
1775
+
1776
+ if nr_edges_left_node > 0 and nr_edges_right_node == 0:
1777
+ # 'left_node' already has a 'person-root' node.
1778
+ personroot = get_or_create_personroot_node(person_node=left_node)
1779
+ if personroot is None:
1780
+ return
1781
+ cypher_create_edge_if_not_exists(left_node_element_id=right_node.element_id,
1782
+ right_node_element_id=personroot.element_id)
1783
+ return
1784
+
1785
+ if nr_edges_left_node == 0 and nr_edges_right_node > 0:
1786
+ # 'right_node' already has a 'person-root' node.
1787
+ personroot = get_or_create_personroot_node(person_node=right_node)
1788
+ if personroot is None:
1789
+ return
1790
+ cypher_create_edge_if_not_exists(left_node_element_id=left_node.element_id,
1791
+ right_node_element_id=personroot.element_id)
1792
+ return
1793
+
1794
+ left_personroot_node = get_or_create_personroot_node(person_node=left_node)
1795
+ right_personroot_node = get_or_create_personroot_node(person_node=right_node)
1796
+ if left_personroot_node is None or right_personroot_node is None:
1797
+ return
1798
+
1799
+ if left_personroot_node == right_personroot_node:
1800
+ # Already connected, nothing to do.
1801
+ return
1802
+
1803
+ # Only continue depending on RICGRAPH_NODEADD_MODE.
1804
+ if RICGRAPH_NODEADD_MODE == 'strict':
1805
+ # For more explanation, see file docs/ricgraph_install_configure.md,
1806
+ # section RICGRAPH_NODEADD_MODE.
1807
+ return
1808
+
1809
+ # Connect crosswise.
1810
+ cypher_create_edge_if_not_exists(left_node_element_id=left_node.element_id,
1811
+ right_node_element_id=right_personroot_node.element_id)
1812
+ cypher_create_edge_if_not_exists(left_node_element_id=right_node.element_id,
1813
+ right_node_element_id=left_personroot_node.element_id)
1814
+
1815
+ time_stamp = datetimestamp()
1816
+ message = 'The node pair "'
1817
+ message += left_node['_key'] + '" and "' + right_node['_key']
1818
+ message += '" caused this node to have more than one neighbor. '
1819
+ message += 'These are its person-root nodes: "'
1820
+ message += left_personroot_node['_key'] + '" and "'
1821
+ message += right_personroot_node['_key'] + '". '
1822
+ message += 'This might be caused by a mislabeling in a harvested system.'
1823
+ timestamped_message = time_stamp + ': ' + message
1824
+
1825
+ print('\nconnect_person_and_person_node(): ' + message)
1826
+ node_property = {'_history': left_node['_history'].copy()}
1827
+ node_property['_history'].append(timestamped_message)
1828
+ cypher_update_node_properties(node_element_id=left_node.element_id,
1829
+ node_properties=node_property)
1830
+ node_property = {'_history': right_node['_history'].copy()}
1831
+ node_property['_history'].append(timestamped_message)
1832
+ cypher_update_node_properties(node_element_id=right_node.element_id,
1833
+ node_properties=node_property)
1834
+ return
1835
+
1836
+
1837
+ def connect_two_nodes(left_node: Node, right_node: Node) -> None:
1838
+ """Connect two nodes with two directed edges.
1839
+
1840
+ In case a node is to contain a personal ID (e.g. ORCID, ISNI, etc.) (then it has
1841
+ category = 'person'), a 'person-root' node will be created if it does not exist.
1842
+ This 'person-root' node is to be seen as the 'representative' for a person.
1843
+ All research outputs will be linked to the 'person-root' node, not to the personal ID node.
1844
+
1845
+ :param left_node: the left node.
1846
+ :param right_node: the right node.
1847
+ :return: None.
1848
+ """
1849
+ global _graph
1850
+
1851
+ if left_node is None or right_node is None:
1852
+ return
1853
+
1854
+ if left_node['category'] != 'person' and right_node['category'] != 'person':
1855
+ # This is not a person to person link, link directly
1856
+ cypher_create_edge_if_not_exists(left_node_element_id=left_node.element_id,
1857
+ right_node_element_id=right_node.element_id)
1858
+ return
1859
+
1860
+ # At least one of the nodes is a 'person' link. These should be linked via their 'person-root' node.
1861
+ if left_node['category'] == 'person' and right_node['category'] != 'person':
1862
+ connect_person_and_non_person_node(person_node=left_node,
1863
+ non_person_node=right_node)
1864
+ return
1865
+
1866
+ if left_node['category'] != 'person' and right_node['category'] == 'person':
1867
+ connect_person_and_non_person_node(person_node=right_node,
1868
+ non_person_node=left_node)
1869
+ return
1870
+
1871
+ connect_person_and_person_node(left_node=left_node,
1872
+ right_node=right_node)
1873
+ return
1874
+
1875
+
1876
+ def create_two_nodes_and_edge(name1: str, category1: str, value1: str,
1877
+ name2: str, category2: str, value2: str,
1878
+ **other_properties: Union[dict, str]) -> None:
1879
+ """Create two nodes (if they do not exist) and two directed edges between those two nodes.
1880
+ The nodes are specified by the properties passed.
1881
+ All 'left' nodes end with '1', all right nodes end with '2'.
1882
+
1883
+ :param name1: 'name' property of left node.
1884
+ :param category1: idem.
1885
+ :param value1: idem.
1886
+ :param name2: 'name' property of right node.
1887
+ :param category2: idem.
1888
+ :param value2: idem
1889
+ :param other_properties: a dictionary of all the other properties.
1890
+ Names ending with '1' belong to a 'left' node,
1891
+ names ending with '2' to a 'right' node.
1892
+ :return: None.
1893
+ """
1894
+ node_properties1 = {}
1895
+ node_properties2 = {}
1896
+ for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
1897
+ for other_name, other_value in other_properties.items():
1898
+ if prop_name + '1' == other_name:
1899
+ node_properties1.update({prop_name: other_value})
1900
+ if prop_name + '2' == other_name:
1901
+ node_properties2.update({prop_name: other_value})
1902
+
1903
+ node1 = create_update_node(name=name1, category=category1, value=value1,
1904
+ other_properties=node_properties1)
1905
+ node2 = create_update_node(name=name2, category=category2, value=value2,
1906
+ other_properties=node_properties2)
1907
+ if node1 is None or node2 is None:
1908
+ return
1909
+
1910
+ connect_two_nodes(left_node=node1, right_node=node2)
1911
+ if node1['name'] == 'FULL_NAME':
1912
+ personroot = get_or_create_personroot_node(person_node=node1)
1913
+ create_name_cache_in_personroot(node=node1, personroot=personroot)
1914
+ if node2['name'] == 'FULL_NAME':
1915
+ personroot = get_or_create_personroot_node(person_node=node2)
1916
+ create_name_cache_in_personroot(node=node2, personroot=personroot)
1917
+ return
1918
+
1919
+
1920
+ def create_nodepairs_and_edges_df(left_and_right_nodepairs: pandas.DataFrame) -> None:
1921
+ """Create two nodes (called 'node pairs') and two directed edges between those nodes.
1922
+ This is done for all the nodes passed. The nodes are specified in a DataFrame.
1923
+ One row in the DataFrame contains two nodes which are to be connected.
1924
+
1925
+ The column names specify the property in a node (i.e. 'name1', 'category1', 'value1').
1926
+ The row value of that column specify the property value of that property.
1927
+ All 'left' nodes end with '1', all right nodes end with '2'.
1928
+
1929
+ :param left_and_right_nodepairs: the node pairs in a DataFrame.
1930
+ :return: None.
1931
+ """
1932
+ print('There are ' + str(len(left_and_right_nodepairs)) + ' rows ('
1933
+ + timestamp() + '), creating nodes and edges for row: 0 ', end='')
1934
+ count = 0
1935
+ columns = left_and_right_nodepairs.columns
1936
+ for row in left_and_right_nodepairs.itertuples():
1937
+ count += 1
1938
+ if count % 250 == 0:
1939
+ print(count, ' ', end='', flush=True)
1940
+ if count % 2500 == 0:
1941
+ print('(' + timestamp() + ')\n', end='', flush=True)
1942
+
1943
+ node_properties = {}
1944
+ for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
1945
+ for other_name in columns:
1946
+ if prop_name + '1' == other_name:
1947
+ node_properties[prop_name + '1'] = getattr(row, other_name)
1948
+ for other_name in columns:
1949
+ if prop_name + '2' == other_name:
1950
+ node_properties[prop_name + '2'] = getattr(row, other_name)
1951
+
1952
+ create_two_nodes_and_edge(name1=row.name1, category1=row.category1, value1=row.value1,
1953
+ name2=row.name2, category2=row.category2, value2=row.value2,
1954
+ **node_properties)
1955
+ print(count, '(' + timestamp() + ')\n', end='', flush=True)
1956
+ return
1957
+
1958
+
1959
+ def create_nodepairs_and_edges_params(name1: Union[dict, str], category1: Union[dict, str], value1: Union[dict, str],
1960
+ name2: Union[dict, str], category2: Union[dict, str], value2: Union[dict, str],
1961
+ **other_properties: Union[dict, str]) -> None:
1962
+ """Create two nodes (called 'node pairs') and two directed edges between those nodes.
1963
+ This is done for all the nodes passed. The nodes are specified by values passed to this function.
1964
+ Each value can be either a string or a dict containing strings.
1965
+ All 'left' nodes end with '1', all right nodes end with '2'.
1966
+
1967
+ If it is a string (like 'ORCID') then that value will be used for every
1968
+ property value for that property. That is, every node pair will have the same value for that property.
1969
+
1970
+ If it is a dict containing strings, then every node pair will have a different value for that property.
1971
+
1972
+ :param name1: 'name' property of left node.
1973
+ :param category1: idem.
1974
+ :param value1: idem.
1975
+ :param name2: 'name' property of right node.
1976
+ :param category2: idem.
1977
+ :param value2: idem.
1978
+ :param other_properties: a dictionary of all the other properties.
1979
+ Names ending with '1' belong to a 'left' node,
1980
+ names ending with '2' to a 'right' node.
1981
+ :return: None.
1982
+ """
1983
+ if isinstance(value1, str):
1984
+ left_and_right_nodepairs = pandas.DataFrame(data={'value1': value1},
1985
+ index=[0])
1986
+ else:
1987
+ left_and_right_nodepairs = pandas.DataFrame(data=value1).copy(deep=True)
1988
+ left_and_right_nodepairs.rename(columns={value1.name: 'value1'}, inplace=True)
1989
+
1990
+ # This makes programming more efficient
1991
+ allowed_properties = RICGRAPH_PROPERTIES_ADDITIONAL + RICGRAPH_PROPERTIES_STANDARD
1992
+ ext_other_properties = other_properties.copy()
1993
+ ext_other_properties['name1'] = name1
1994
+ ext_other_properties['name2'] = name2
1995
+ ext_other_properties['category1'] = category1
1996
+ ext_other_properties['category2'] = category2
1997
+ # 'value1' has been included above
1998
+ ext_other_properties['value2'] = value2
1999
+
2000
+ for prop_name in allowed_properties:
2001
+ for other_name in ext_other_properties:
2002
+ other_value = ext_other_properties[other_name]
2003
+ if prop_name + '1' == other_name:
2004
+ if isinstance(other_value, str):
2005
+ left_and_right_nodepairs[prop_name + '1'] = other_value
2006
+ else:
2007
+ left_and_right_nodepairs = pandas.concat([left_and_right_nodepairs, other_value], axis='columns')
2008
+ left_and_right_nodepairs.rename(columns={other_value.name: prop_name + '1'}, inplace=True)
2009
+
2010
+ if prop_name + '2' == other_name:
2011
+ if isinstance(other_value, str):
2012
+ left_and_right_nodepairs[prop_name + '2'] = other_value
2013
+ else:
2014
+ left_and_right_nodepairs = pandas.concat([left_and_right_nodepairs, other_value], axis='columns')
2015
+ left_and_right_nodepairs.rename(columns={other_value.name: prop_name + '2'}, inplace=True)
2016
+
2017
+ # Reorder a bit, starting with the last, only the name-category-value property, since they will surely exist
2018
+ left_and_right_nodepairs.insert(0, 'value2', left_and_right_nodepairs.pop('value2'))
2019
+ left_and_right_nodepairs.insert(0, 'category2', left_and_right_nodepairs.pop('category2'))
2020
+ left_and_right_nodepairs.insert(0, 'name2', left_and_right_nodepairs.pop('name2'))
2021
+ left_and_right_nodepairs.insert(0, 'value1', left_and_right_nodepairs.pop('value1'))
2022
+ left_and_right_nodepairs.insert(0, 'category1', left_and_right_nodepairs.pop('category1'))
2023
+ left_and_right_nodepairs.insert(0, 'name1', left_and_right_nodepairs.pop('name1'))
2024
+
2025
+ create_nodepairs_and_edges_df(left_and_right_nodepairs=left_and_right_nodepairs)
2026
+ return
2027
+
2028
+
2029
+ def unify_personal_identifiers(personal_identifiers: pandas.DataFrame,
2030
+ source_event: Union[dict, str], history_event: Union[dict, str]) -> None:
2031
+ """Unify a collection of personal identifiers (e.g. ORCID, ISNI, etc.).
2032
+ That means that every column is unified to every other column.
2033
+ The identifiers are specified as columns in a DataFrame.
2034
+
2035
+ :param personal_identifiers: DataFrame containing personal identifiers.
2036
+ :param source_event: an event to add to the _source list.
2037
+ :param history_event: a history event to add.
2038
+ :return: None.
2039
+ """
2040
+ # Use i & j to make sure not to unify twice
2041
+ i = 0
2042
+ for column1 in personal_identifiers.columns:
2043
+ i += 1
2044
+ j = 0
2045
+ for column2 in personal_identifiers.columns:
2046
+ j += 1
2047
+ if i >= j:
2048
+ continue
2049
+ print('\nUnifying personal identifiers ' + column1 + ' and ' + column2
2050
+ + ' at ' + timestamp() + '...')
2051
+ identifiers = personal_identifiers[[column1, column2]].copy(deep=True)
2052
+
2053
+ # Ensure that all '' values are NaN, so that those rows can be easily removed with dropna()
2054
+ identifiers.replace('', numpy.nan, inplace=True)
2055
+ # dropna(how='any'): drop row if one or more row values contain NaN
2056
+ identifiers.dropna(axis=0, how='any', inplace=True)
2057
+ identifiers.drop_duplicates(keep='first', inplace=True, ignore_index=True)
2058
+
2059
+ if RICGRAPH_NODEADD_MODE == 'strict':
2060
+ # Remove rows which have a cell value that occurs more than once.
2061
+ # E.g. if we have a DataFrame:
2062
+ # FULL_NAME ORCID
2063
+ # Name1 10
2064
+ # Name2 11
2065
+ # Name3 11
2066
+ # then we remove both rows with ORCID 11.
2067
+ # For more explanation, see file docs/ricgraph_install_configure.md,
2068
+ # section RICGRAPH_NODEADD_MODE.
2069
+ identifiers.drop_duplicates(subset=column1, keep=False, inplace=True, ignore_index=True)
2070
+ identifiers.drop_duplicates(subset=column2, keep=False, inplace=True, ignore_index=True)
2071
+
2072
+ create_nodepairs_and_edges_params(name1=column1, category1='person', value1=identifiers[column1],
2073
+ name2=column2, category2='person', value2=identifiers[column2],
2074
+ source_event1=source_event, source_event2=source_event,
2075
+ history_event1=history_event, history_event2=history_event)
2076
+ print('\nDone at ' + timestamp() + '.\n')
2077
+ return
2078
+
2079
+
2080
+ def print_node_values(node: Node) -> None:
2081
+ """Print the values of all properties in a node.
2082
+
2083
+ :param node: the node.
2084
+ :return: None.
2085
+ """
2086
+ print('Node values:')
2087
+ print('_key: ' + node['_key'])
2088
+ print('name: ' + node['name'])
2089
+ print('category: ' + node['category'])
2090
+ print('value: ' + node['value'])
2091
+ for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
2092
+ print(prop_name + ': ' + node[prop_name])
2093
+ print('source:')
2094
+ for source in node['_source']:
2095
+ print('- ' + source)
2096
+ print('history:')
2097
+ for history in node['_history']:
2098
+ print('- ' + history)
2099
+ print('')
2100
+ return
2101
+
2102
+
2103
+ # ##############################################################################
2104
+ # Neighbor node and edge functions.
2105
+ # These start with "get_", not with "read_" as some above.
2106
+ # The difference is that "get_" functions work in constant time (starting from
2107
+ # a node) while the "read_" functions read (aka search, find) the whole graph.
2108
+ # ##############################################################################
2109
+
2110
+
2111
+ # ######
2112
+ # This function does not seem necessary. Use get_all_neighbor_nodes() or
2113
+ # ricgraph_nr_edges_of_node().
2114
+ # Note that this function returns a list of edges, but you cannot get the next node
2115
+ # directly from an edge in that list.
2116
+ # ######
2117
+ # def get_edges(node: Node) -> list:
2118
+ # """Get the edges attached to a node.
2119
+ #
2120
+ # :param node: the node.
2121
+ # :return: a list of the edges, or empty list if no edges are connected.
2122
+ # """
2123
+ # global _graph
2124
+ #
2125
+ # cypher_query = 'MATCH (node)-[edge]->() WHERE elementId(node)=$node_element_id RETURN edge'
2126
+ # edges = _graph.execute_query(cypher_query,
2127
+ # node_element_id=node.element_id,
2128
+ # result_transformer_=Result.value,
2129
+ # database_=ricgraph_databasename())
2130
+ # if len(edges) == 0:
2131
+ # return []
2132
+ # else:
2133
+ # return edges
2134
+ # ######
2135
+
2136
+
2137
+ def get_personroot_node(node: Node) -> Union[Node, None]:
2138
+ """Get the 'person-root' node for any type of node.
2139
+ If 'node' is already a 'person-root' node, return 'node'.
2140
+ If there is more than one person-root node (which should not happen
2141
+ if the category is 'person'), return the first.
2142
+
2143
+ :param node: the node.
2144
+ :return: the person-root node.
2145
+ """
2146
+ personroot_nodes = get_all_personroot_nodes(node)
2147
+ if len(personroot_nodes) == 0:
2148
+ return None
2149
+ else:
2150
+ return personroot_nodes[0]
2151
+
2152
+
2153
+ def get_all_personroot_nodes(node: Node) -> list:
2154
+ """Get the 'person-root' node(s) for any type of node.
2155
+ If 'node' is already a 'person-root' node, return 'node'.
2156
+ If there is more than one person-root node (which can happen if
2157
+ node is e.g. a research output, and which should not happen
2158
+ if the category is 'person'), all will be returned in a list.
2159
+
2160
+ :param node: the node.
2161
+ :return: a list of all the person-root nodes found.
2162
+ """
2163
+ if node is None:
2164
+ return []
2165
+
2166
+ if node['name'] == 'person-root':
2167
+ return [node]
2168
+
2169
+ personroot_nodes = get_all_neighbor_nodes(node=node,
2170
+ name_want='person-root')
2171
+ return personroot_nodes
2172
+
2173
+
2174
+ def get_all_neighbor_nodes(node: Node,
2175
+ name_want: Union[str, list] = '',
2176
+ name_dontwant: Union[str, list] = '',
2177
+ category_want: Union[str, list] = '',
2178
+ category_dontwant: Union[str, list] = '',
2179
+ max_nr_neighbor_nodes: int = 0) -> list:
2180
+ """Get all the neighbors of 'node' in a list.
2181
+ You can restrict the nodes returned by specifying one or more of the
2182
+ other parameters. If more than one is specified, the result is an AND.
2183
+
2184
+ :param node: the node we need neighbors from.
2185
+ :param name_want: either a string which indicates that we only want neighbor
2186
+ nodes where the property 'name' is equal to 'name_want'
2187
+ (e.g. 'ORCID'),
2188
+ or a list containing several node names, indicating
2189
+ that we want all neighbor nodes where the property 'name' equals
2190
+ one of the names in the list 'name_want'
2191
+ (e.g. ['ORCID', 'ISNI', 'FULL_NAME']).
2192
+ If empty (empty string), return all nodes.
2193
+ :param name_dontwant: similar, but for property 'name' and nodes we don't want.
2194
+ If empty (empty string), all nodes are 'wanted'.
2195
+ :param category_want: similar to 'name_want', but now for the property 'category'.
2196
+ :param category_dontwant: similar, but for property 'category' and nodes we don't want.
2197
+ :param max_nr_neighbor_nodes: return at most this number of nodes, 0 = all nodes.
2198
+ :return: the list of neighboring nodes satisfying all these criteria, or
2199
+ empty list if nothing found.
2200
+ """
2201
+ global _graph
2202
+ global graphdb_nr_reads
2203
+
2204
+ if _graph is None:
2205
+ print('\nget_all_neighbor_nodes(): Error: graph has not been initialized or opened.\n\n')
2206
+ return []
2207
+
2208
+ if node is None:
2209
+ return []
2210
+
2211
+ if name_want is None:
2212
+ name_want = []
2213
+ if name_dontwant is None:
2214
+ name_dontwant = []
2215
+ if category_want is None:
2216
+ category_want = []
2217
+ if category_dontwant is None:
2218
+ category_dontwant = []
2219
+
2220
+ cypher_query = 'MATCH (node:RicgraphNode)-[]->(neighbor:RicgraphNode) '
2221
+ if ricgraph_database() == 'neo4j':
2222
+ cypher_query += 'WHERE (elementId(node)=$node_element_id) '
2223
+ else:
2224
+ cypher_query += 'WHERE (id(node)=toInteger($node_element_id)) '
2225
+
2226
+ nr_of_not_clauses = 0
2227
+ if isinstance(name_want, str):
2228
+ if name_want != '':
2229
+ cypher_query += 'AND (neighbor.name IN ["' + name_want + '"]) '
2230
+ else:
2231
+ if len(name_want) > 0:
2232
+ string_list = '["' + '", "'.join(str(item) for item in name_want) + '"]'
2233
+ cypher_query += 'AND (neighbor.name IN ' + string_list + ') '
2234
+
2235
+ if isinstance(name_dontwant, str):
2236
+ if name_dontwant != '':
2237
+ cypher_query += 'AND (NOT neighbor.name IN ["' + name_dontwant + '"]) '
2238
+ nr_of_not_clauses += 1
2239
+ else:
2240
+ if len(name_dontwant) > 0:
2241
+ string_list = '["' + '", "'.join(str(item) for item in name_dontwant) + '"]'
2242
+ cypher_query += 'AND (NOT neighbor.name IN ' + string_list + ') '
2243
+ nr_of_not_clauses += 1
2244
+
2245
+ if isinstance(category_want, str):
2246
+ if category_want != '':
2247
+ cypher_query += 'AND (neighbor.category IN ["' + category_want + '"]) '
2248
+ else:
2249
+ if len(category_want) > 0:
2250
+ string_list = '["' + '", "'.join(str(item) for item in category_want) + '"]'
2251
+ cypher_query += 'AND (neighbor.category IN ' + string_list + ') '
2252
+
2253
+ if isinstance(category_dontwant, str):
2254
+ if category_dontwant != '':
2255
+ cypher_query += 'AND (NOT neighbor.category IN ["' + category_dontwant + '"]) '
2256
+ nr_of_not_clauses += 1
2257
+ else:
2258
+ if len(category_dontwant) > 0:
2259
+ string_list = '["' + '", "'.join(str(item) for item in category_dontwant) + '"]'
2260
+ cypher_query += 'AND (NOT neighbor.category IN ' + string_list + ') '
2261
+ nr_of_not_clauses += 1
2262
+
2263
+ if nr_of_not_clauses >= 2:
2264
+ # This is a special case in which the Cypher query produces unexpected results.
2265
+ neighbor_nodes = get_all_neighbor_nodes_loop(node=node,
2266
+ name_want=name_want,
2267
+ name_dontwant=name_dontwant,
2268
+ category_want=category_want,
2269
+ category_dontwant=category_dontwant,
2270
+ max_nr_neighbor_nodes=max_nr_neighbor_nodes)
2271
+ return neighbor_nodes
2272
+
2273
+ cypher_query += 'RETURN DISTINCT neighbor '
2274
+ if max_nr_neighbor_nodes > 0:
2275
+ cypher_query += 'LIMIT ' + str(max_nr_neighbor_nodes)
2276
+ # print(cypher_query)
2277
+
2278
+ neighbor_nodes = _graph.execute_query(cypher_query,
2279
+ node_element_id=node.element_id,
2280
+ result_transformer_=Result.value,
2281
+ database_=ricgraph_databasename())
2282
+ nr_neighbors = len(neighbor_nodes)
2283
+ # Unsure what to count here, this seems reasonable. '+ 1' for 'node'.
2284
+ graphdb_nr_reads += nr_neighbors + 1
2285
+ if nr_neighbors == 0:
2286
+ return []
2287
+ else:
2288
+ return neighbor_nodes
2289
+
2290
+
2291
+ def get_all_neighbor_nodes_loop(node: Node,
2292
+ name_want: Union[str, list] = '',
2293
+ name_dontwant: Union[str, list] = '',
2294
+ category_want: Union[str, list] = '',
2295
+ category_dontwant: Union[str, list] = '',
2296
+ max_nr_neighbor_nodes: int = 0) -> list:
2297
+ """Get all the neighbors of 'node' in a list.
2298
+ You can restrict the nodes returned by specifying one or more of the
2299
+ other parameters. If more than one is specified, the result is an AND.
2300
+
2301
+ This is the same function as get_all_neighbor_nodes() but it is much slower
2302
+ since it uses a loop. We need this in case
2303
+ len(name_dontwant_list) > 0 and len(category_dontwant_list) > 0.
2304
+
2305
+ :param node: the node we need neighbors from.
2306
+ :param name_want: either a string which indicates that we only want neighbor
2307
+ nodes where the property 'name' is equal to 'name_want'
2308
+ (e.g. 'ORCID'),
2309
+ or a list containing several node names, indicating
2310
+ that we want all neighbor nodes where the property 'name' equals
2311
+ one of the names in the list 'name_want'
2312
+ (e.g. ['ORCID', 'ISNI', 'FULL_NAME']).
2313
+ If empty (empty string), return all nodes.
2314
+ :param name_dontwant: similar, but for property 'name' and nodes we don't want.
2315
+ If empty (empty string), all nodes are 'wanted'.
2316
+ :param category_want: similar to 'name_want', but now for the property 'category'.
2317
+ :param category_dontwant: similar, but for property 'category' and nodes we don't want.
2318
+ :param max_nr_neighbor_nodes: return at most this number of nodes, 0 = all nodes.
2319
+ :return: the list of neighboring nodes satisfying all these criteria.
2320
+ """
2321
+ global _graph
2322
+ global graphdb_nr_reads
2323
+
2324
+ if _graph is None:
2325
+ print('\nget_all_neighbor_nodes_loop(): Error: graph has not been initialized or opened.\n\n')
2326
+ return []
2327
+
2328
+ if node is None:
2329
+ return []
2330
+
2331
+ candidate_neighbor_nodes = get_all_neighbor_nodes(node=node)
2332
+ if len(candidate_neighbor_nodes) == 0:
2333
+ return []
2334
+
2335
+ # If 'name_want' etc. are strings, convert them to lists.
2336
+ if isinstance(name_want, str):
2337
+ if name_want == '':
2338
+ name_want_list = []
2339
+ else:
2340
+ name_want_list = [name_want]
2341
+ else:
2342
+ name_want_list = name_want
2343
+
2344
+ if isinstance(name_dontwant, str):
2345
+ if name_dontwant == '':
2346
+ name_dontwant_list = []
2347
+ else:
2348
+ name_dontwant_list = [name_dontwant]
2349
+ else:
2350
+ name_dontwant_list = name_dontwant
2351
+
2352
+ if isinstance(category_want, str):
2353
+ if category_want == '':
2354
+ category_want_list = []
2355
+ else:
2356
+ category_want_list = [category_want]
2357
+ else:
2358
+ category_want_list = category_want
2359
+
2360
+ if isinstance(category_dontwant, str):
2361
+ if category_dontwant == '':
2362
+ category_dontwant_list = []
2363
+ else:
2364
+ category_dontwant_list = [category_dontwant]
2365
+ else:
2366
+ category_dontwant_list = category_dontwant
2367
+
2368
+ count = 0
2369
+ all_nodes = A_LARGE_NUMBER
2370
+ if max_nr_neighbor_nodes == 0:
2371
+ max_nr_neighbor_nodes = all_nodes
2372
+ neighbor_nodes = []
2373
+ for neighbor in candidate_neighbor_nodes:
2374
+ if count >= max_nr_neighbor_nodes:
2375
+ break
2376
+ if node == neighbor:
2377
+ continue
2378
+ if neighbor['name'] in name_dontwant_list:
2379
+ continue
2380
+ if neighbor['category'] in category_dontwant_list:
2381
+ continue
2382
+ if len(name_want) == 0 and len(category_want) == 0:
2383
+ neighbor_nodes.append(neighbor)
2384
+ count += 1
2385
+ continue
2386
+ if neighbor['name'] in name_want_list \
2387
+ and neighbor['category'] in category_want_list:
2388
+ neighbor_nodes.append(neighbor)
2389
+ count += 1
2390
+ continue
2391
+ if neighbor['name'] in name_want_list and len(category_want_list) == 0:
2392
+ neighbor_nodes.append(neighbor)
2393
+ count += 1
2394
+ continue
2395
+ if len(name_want_list) == 0 and neighbor['category'] in category_want_list:
2396
+ neighbor_nodes.append(neighbor)
2397
+ count += 1
2398
+ continue
2399
+ # Any other next_node we do not want.
2400
+
2401
+ # Unsure what to count here, this seems reasonable. '+ 1' for 'node'.
2402
+ graphdb_nr_reads += len(neighbor_nodes) + 1
2403
+ return neighbor_nodes
2404
+
2405
+
2406
+ # ##############################################################################
2407
+ # Utility functions
2408
+ # ##############################################################################
2409
+
2410
+ def create_multidimensional_dict(dimension: int, dict_type):
2411
+ """Create a multimensional dict.
2412
+ Non-existing keys will be added automatically.
2413
+ From https://stackoverflow.com/questions/29348345/declaring-a-multi-dimensional-dictionary-in-python,
2414
+ the second answer.
2415
+
2416
+ Example use:
2417
+ new_dict = create_multidimensional_dict(2, int)
2418
+ new_dict['key1']['key2'] += 5
2419
+
2420
+ :param dimension: dimension of the dict.
2421
+ :param dict_type: type of the dict.
2422
+ :return: the dict.
2423
+ """
2424
+ if dimension == 0:
2425
+ return None
2426
+ if dimension == 1:
2427
+ return defaultdict(dict_type)
2428
+ else:
2429
+ return defaultdict(lambda: create_multidimensional_dict(dimension=dimension - 1,
2430
+ dict_type=dict_type))
2431
+
2432
+
2433
+ def lookup_resout_type(research_output_type: str,
2434
+ research_output_mapping: dict) -> str:
2435
+ """Convert a research output type from a harvested system
2436
+ to a shorter and easier Ricgraph research output type, according to a certain mapping.
2437
+ The reason for doing this is to ensure a constant naming of research output
2438
+ types for objects harvested from different sources.
2439
+ For more explanation, see the text at 'Research output types used in Ricgraph' at
2440
+ the start of this file.
2441
+
2442
+ :param research_output_type: A research output type from a source system.
2443
+ :param research_output_mapping: The mapping from the source system to Ricgraph
2444
+ research output types.
2445
+ :return: The result, in a few words.
2446
+ """
2447
+ if research_output_type == '':
2448
+ print('lookup_resout_type(): no research output type specified .')
2449
+ return 'empty'
2450
+
2451
+ if research_output_type not in research_output_mapping:
2452
+ print('lookup_resout_type(): unknown research output type: "' + research_output_type + '".')
2453
+ return 'unknown'
2454
+
2455
+ return research_output_mapping[research_output_type]
2456
+
2457
+
2458
+ # #####
2459
+ # Note:
2460
+ # This function was written using the JSON harvested by the following python files:
2461
+ # - GET: harvest_openalex_to_ricgraph.py.
2462
+ # - POST: harvest_pure_to_ricgraph.py.
2463
+ #
2464
+ # Possibly for other harvests some changes should be made. Please also test the result
2465
+ # with the above-mentioned files, and add the filename of the new harvest script.
2466
+ # #####
2467
+ def harvest_json(url: str, headers: dict, body: dict = None, max_recs_to_harvest: int = 0, chunksize: int = 0) -> list:
2468
+ """Harvest json data from a file.
2469
+
2470
+ :param url: URL to harvest.
2471
+ :param headers: headers required.
2472
+ :param body: the body of a POST request, or [] for a GET request.
2473
+ :param max_recs_to_harvest: maximum records to harvest.
2474
+ :param chunksize: chunk size to use (i.e. the number of records harvested in one call to 'url').
2475
+ :return: list of records in json format, or empty list if nothing found.
2476
+ """
2477
+ if body is None:
2478
+ body = []
2479
+
2480
+ print('Harvesting json data from ' + url + '.')
2481
+ print('Getting data at ' + datetimestamp() + '...')
2482
+
2483
+ all_records = A_LARGE_NUMBER
2484
+ if max_recs_to_harvest == 0:
2485
+ max_recs_to_harvest = all_records
2486
+ if chunksize == 0:
2487
+ chunksize = 1
2488
+ if len(body) == 0:
2489
+ # GET http request
2490
+ request_type = 'get'
2491
+ url += '&per_page=' + str(chunksize)
2492
+ else:
2493
+ # POST http request
2494
+ request_type = 'post'
2495
+ body['size'] = chunksize
2496
+
2497
+ # Do a first harvest to determine the number of records to harvest.
2498
+ if request_type == 'get':
2499
+ response = requests.get(url=url, headers=headers)
2500
+ else:
2501
+ response = requests.post(url=url, headers=headers, json=body)
2502
+
2503
+ if response.status_code != requests.codes.ok:
2504
+ print('harvest_json(): error during harvest, possibly '
2505
+ + 'a missing API-key or mixed up POST body?')
2506
+ print('Status code: ' + str(response.status_code))
2507
+ print('Url: ' + response.url)
2508
+ print('Error: ' + response.text)
2509
+ exit(1)
2510
+
2511
+ chunk_json_data = response.json()
2512
+ total_records = 0
2513
+ if request_type == 'get':
2514
+ if 'meta' in chunk_json_data:
2515
+ if 'count' in chunk_json_data['meta']:
2516
+ total_records = chunk_json_data['meta']['count']
2517
+ else:
2518
+ if 'count' in chunk_json_data:
2519
+ total_records = chunk_json_data['count']
2520
+
2521
+ if total_records == 0:
2522
+ print('harvest_json(): Warning: malformed json, "count" is missing.')
2523
+
2524
+ if max_recs_to_harvest == all_records:
2525
+ print('There are ' + str(total_records)
2526
+ + ' records, harvesting in chunks of ' + str(chunksize)
2527
+ + ' items.')
2528
+ else:
2529
+ print('There are ' + str(total_records)
2530
+ + ' records, harvesting in chunks of ' + str(chunksize)
2531
+ + ', at most ' + str(max_recs_to_harvest) + ' items.')
2532
+
2533
+ print('Harvesting record: ', end='', flush=True)
2534
+
2535
+ json_data = []
2536
+ records_harvested = 0
2537
+ page_nr = 1
2538
+ # And now start the real harvesting.
2539
+ while records_harvested <= max_recs_to_harvest:
2540
+ if request_type == 'get':
2541
+ url_page = url + '&page=' + str(page_nr)
2542
+ response = requests.get(url=url_page, headers=headers)
2543
+ else:
2544
+ if max_recs_to_harvest - records_harvested <= chunksize:
2545
+ # We have to harvest the last few (< chunksize).
2546
+ body['size'] = max_recs_to_harvest - records_harvested
2547
+ body['offset'] = records_harvested
2548
+ response = requests.post(url=url, headers=headers, json=body)
2549
+
2550
+ if response.status_code != requests.codes.ok:
2551
+ print('harvest_json(): error during harvest, possibly '
2552
+ + 'a missing API-key or mixed up POST body?')
2553
+ print('Status code: ' + str(response.status_code))
2554
+ print('Url: ' + response.url)
2555
+ print('Error: ' + response.text)
2556
+ exit(1)
2557
+
2558
+ chunk_json_data = response.json()
2559
+ if request_type == 'get':
2560
+ if 'results' not in chunk_json_data:
2561
+ print('harvest_json(): Error: malformed json, "results" is missing.')
2562
+ return []
2563
+ if len(chunk_json_data['results']) == 0:
2564
+ break
2565
+ json_items = chunk_json_data['results']
2566
+ else:
2567
+ if 'items' not in chunk_json_data:
2568
+ print('harvest_json(): Error: malformed json, "items" is missing.')
2569
+ return []
2570
+ if len(chunk_json_data['items']) == 0:
2571
+ break
2572
+ json_items = chunk_json_data['items']
2573
+
2574
+ json_data += json_items
2575
+ print(records_harvested, ' ', end='', flush=True)
2576
+ if page_nr % 10 == 0:
2577
+ if page_nr != 0:
2578
+ print('(' + timestamp() + ')\n', end='', flush=True)
2579
+ records_harvested += chunksize
2580
+ page_nr += 1
2581
+
2582
+ print(' Done at ' + timestamp() + '.\n')
2583
+ return json_data
2584
+
2585
+
2586
+ def harvest_json_and_write_to_file(filename: str, url: str, headers: dict, body: dict = None,
2587
+ max_recs_to_harvest: int = 0, chunksize: int = 0) -> list:
2588
+ """Harvest json data and write the data found to a file.
2589
+ This data is a list of records in json format. If no records are harvested, nothing is written.
2590
+
2591
+ :param filename: filename of the file to use for writing.
2592
+ :param url: URL to harvest.
2593
+ :param headers: headers required.
2594
+ :param body: the body of a POST request, or '' for a GET request.
2595
+ :param max_recs_to_harvest: maximum records to harvest.
2596
+ :param chunksize: chunk size to use (i.e. the number of records harvested in one call to 'url').
2597
+ :return: list of records in json format, or empty list if nothing found.
2598
+ """
2599
+ if body is None:
2600
+ body = []
2601
+
2602
+ json_data = harvest_json(url=url,
2603
+ headers=headers,
2604
+ body=body,
2605
+ max_recs_to_harvest=max_recs_to_harvest,
2606
+ chunksize=chunksize)
2607
+ if len(json_data) == 0:
2608
+ return []
2609
+ write_json_to_file(json_data=json_data, filename=filename)
2610
+ return json_data
2611
+
2612
+
2613
+ def write_json_to_file(json_data: list, filename: str) -> None:
2614
+ """Write json data to a file.
2615
+ If no records are harvested, nothing is written.
2616
+
2617
+ :param json_data: a list of records in json format.
2618
+ :param filename: filename of the file to use for writing.
2619
+ :return: None.
2620
+ """
2621
+ print('Saving json data to ' + filename + '... ', end='', flush=True)
2622
+ with open(filename, 'w') as fd:
2623
+ json.dump(obj=json_data, fp=fd, ensure_ascii=False, indent=2)
2624
+ print('Done.')
2625
+ return
2626
+
2627
+
2628
+ def read_json_from_file(filename: str) -> list:
2629
+ """Read json data from a file.
2630
+
2631
+ :param filename: filename of the file to use for writing.
2632
+ :return: list of records in json format, or empty list if nothing found.
2633
+ """
2634
+ print('Reading json data from ' + filename + '... ', end='', flush=True)
2635
+ with open(filename) as fd:
2636
+ json_data = json.load(fp=fd)
2637
+ print('Done.')
2638
+ if json_data is None:
2639
+ return []
2640
+ return json_data
2641
+
2642
+
2643
+ def read_dataframe_from_csv(filename: str, columns: dict = None,
2644
+ nr_rows: int = None, datatype=None) -> pandas.DataFrame:
2645
+ """Reads a datastructure (DataFrame) from file
2646
+
2647
+ :param filename: csv file to read.
2648
+ :param columns: which columns to read.
2649
+ :param nr_rows: how many rows to read (default: all).
2650
+ :param datatype: type of (some) columns to read.
2651
+ :return: dataframe read.
2652
+ """
2653
+ try:
2654
+ # Some inputfiles have problems reading in utf-8
2655
+ csv_data = pandas.read_csv(filename,
2656
+ sep=',',
2657
+ usecols=columns,
2658
+ dtype=datatype,
2659
+ engine='python',
2660
+ nrows=nr_rows,
2661
+ parse_dates=False,
2662
+ iterator=False,
2663
+ quotechar='"',
2664
+ encoding='utf-8')
2665
+ except BaseException:
2666
+ print('read_dataframe_from_csv(): error reading in utf-8 format, reading in latin-1 format.')
2667
+ csv_data = pandas.read_csv(filename,
2668
+ sep=',',
2669
+ usecols=columns,
2670
+ dtype=datatype,
2671
+ engine='python',
2672
+ nrows=nr_rows,
2673
+ parse_dates=False,
2674
+ iterator=False,
2675
+ quotechar='"',
2676
+ encoding='latin-1')
2677
+ return csv_data
2678
+
2679
+
2680
+ def write_dataframe_to_csv(filename: str, df: pandas.DataFrame) -> None:
2681
+ """Write a datastructure (DataFrame) to file.
2682
+
2683
+ :param filename: csv file to write.
2684
+ :param df: dataframe to write.
2685
+ :return: None.
2686
+ """
2687
+ print('\nWriting to csv file: ' + filename + '... ', end='', flush=True)
2688
+ df.to_csv(filename,
2689
+ sep=',',
2690
+ quotechar='"',
2691
+ quoting=csv.QUOTE_ALL,
2692
+ encoding='utf-8',
2693
+ index=False)
2694
+ print('Done.')
2695
+ return
2696
+
2697
+
2698
+ def convert_nodes_to_list_of_dict(nodes_list: list,
2699
+ max_nr_items: str = '0') -> list:
2700
+ """Convert a list of nodes to a list of dict.
2701
+
2702
+ :param nodes_list: The list of nodes.
2703
+ :param max_nr_items: The maximum number of items to return.
2704
+ :return: A list of dicts
2705
+ """
2706
+ if max_nr_items == '0':
2707
+ max_nr_nodes = A_LARGE_NUMBER
2708
+ else:
2709
+ max_nr_nodes = int(max_nr_items)
2710
+
2711
+ result_list = []
2712
+ count = 0
2713
+ for node in nodes_list:
2714
+ count += 1
2715
+ if count > max_nr_nodes:
2716
+ break
2717
+ result = {}
2718
+ for item in node:
2719
+ result[item] = node[item]
2720
+ result_list.append(result)
2721
+ return result_list
2722
+
2723
+
2724
+ def create_http_response(result_list: list = None,
2725
+ message: str = '',
2726
+ http_status: int = HTTP_RESPONSE_OK) -> Tuple[dict, int]:
2727
+ """Create an HTTP response.
2728
+
2729
+ :param result_list: A list of dicts, to be put in the 'result' section of
2730
+ the response.
2731
+ :param message: An optional message to be put in the 'meta' section of
2732
+ the response.
2733
+ :param http_status: The HTTP status code to be put in the 'meta' section of
2734
+ the response.
2735
+ :return: An HTTP response (as dict, to be translated to json)
2736
+ and an HTTP response code.
2737
+ """
2738
+ if result_list is None:
2739
+ result_list = []
2740
+
2741
+ meta = {'count': len(result_list),
2742
+ 'page': 1, # More pages not implemented yet.
2743
+ 'per_page': len(result_list), # Should be page length, not implemented yet.
2744
+ 'status': http_status}
2745
+ if message != '':
2746
+ meta['message'] = message
2747
+
2748
+ response = {'meta': meta,
2749
+ 'results': result_list}
2750
+ return response, http_status
2751
+
2752
+
2753
+ def print_commandline_arguments(argument_list: list) -> None:
2754
+ """Print the script name and all command line arguments.
2755
+
2756
+ :param argument_list: the argument list.
2757
+ :return: None.
2758
+ """
2759
+ if len(argument_list) == 0:
2760
+ print('print_commandline_arguments(): Error, empty argument_list passed, exiting.')
2761
+ exit(1)
2762
+
2763
+ print('\nScript name: ' + argument_list[0])
2764
+ if len(argument_list) == 1:
2765
+ print('Command line arguments: [none]')
2766
+ else:
2767
+ print('Command line arguments: '
2768
+ + ' '.join([str(arg) for arg in argument_list[1:]])
2769
+ + '\n')
2770
+ return
2771
+
2772
+
2773
+ def get_commandline_argument(argument: str, argument_list: list) -> str:
2774
+ """Get the value of a command line argument
2775
+
2776
+ :param argument: the command line argument name.
2777
+ :param argument_list: the argument list.
2778
+ :return: the value that belongs to 'argument'.
2779
+ """
2780
+ length = len(argument_list)
2781
+ if length == 0:
2782
+ print('get_commandline_argument(): Error, empty argument_list passed, exiting.')
2783
+ exit(1)
2784
+
2785
+ if str(argument) == '':
2786
+ # No argument passed.
2787
+ return ''
2788
+
2789
+ if length == 1:
2790
+ # The argument list contains the script name only.
2791
+ return ''
2792
+
2793
+ for i in range(1, length - 1):
2794
+ if str(argument_list[i]) == str(argument):
2795
+ if i + 1 <= length:
2796
+ # Only get the next index if we are still in the array bounds.
2797
+ return str(argument_list[i + 1])
2798
+ return ''
2799
+
2800
+
2801
+ # ############################################
2802
+ # ################### main ###################
2803
+ # ############################################
2804
+ # This will be executed on module initialization
2805
+ config = configparser.ConfigParser()
2806
+ config.read(get_ricgraph_ini_file())
2807
+ try:
2808
+ RICGRAPH_PROPERTIES_STANDARD = tuple(config['Ricgraph']['ricgraph_properties_standard'].split(','))
2809
+ RICGRAPH_PROPERTIES_HIDDEN = tuple(config['Ricgraph']['ricgraph_properties_hidden'].split(','))
2810
+ RICGRAPH_PROPERTIES_ADDITIONAL = tuple(config['Ricgraph']['ricgraph_properties_additional'].split(','))
2811
+ if len(RICGRAPH_PROPERTIES_STANDARD) == 0 \
2812
+ or len(RICGRAPH_PROPERTIES_HIDDEN) == 0 \
2813
+ or len(RICGRAPH_PROPERTIES_ADDITIONAL) == 0:
2814
+ print('Ricgraph initialization: error, ricgraph_properties_standard and/or')
2815
+ print(' ricgraph_properties_hidden and/or ricgraph_properties_additional')
2816
+ print(' are empty in Ricgraph ini file, exiting.')
2817
+ exit(1)
2818
+
2819
+ if RICGRAPH_PROPERTIES_STANDARD[0] == '' \
2820
+ or RICGRAPH_PROPERTIES_HIDDEN[0] == '' \
2821
+ or RICGRAPH_PROPERTIES_ADDITIONAL[0] == '':
2822
+ print('Ricgraph initialization: error, ricgraph_properties_standard and/or')
2823
+ print(' ricgraph_properties_hidden and/or ricgraph_properties_additional')
2824
+ print(' are empty in Ricgraph ini file, exiting.')
2825
+ exit(1)
2826
+
2827
+ # For more explanation, see file docs/ricgraph_install_configure.md,
2828
+ # section RICGRAPH_NODEADD_MODE.
2829
+ RICGRAPH_NODEADD_MODE = config['Ricgraph']['ricgraph_nodeadd_mode']
2830
+ if RICGRAPH_NODEADD_MODE != 'strict' and RICGRAPH_NODEADD_MODE != 'lenient':
2831
+ print('Ricgraph initialization: error, unknown value "' + RICGRAPH_NODEADD_MODE
2832
+ + '" for ricgraph_nodeadd_mode in Ricgraph ini file, exiting.')
2833
+ exit(1)
2834
+ else:
2835
+ print('Ricgraph is using "' + RICGRAPH_NODEADD_MODE + '" for ricgraph_nodeadd_mode.')
2836
+ except KeyError:
2837
+ print('Ricgraph initialization: error, ricgraph_properties_standard and/or')
2838
+ print(' ricgraph_properties_hidden and/or ricgraph_properties_additional and/or ricgraph_nodeadd_mode')
2839
+ print(' not found in Ricgraph ini file, exiting.')
2840
+ exit(1)
2841
+
2842
+ try:
2843
+ GRAPHDB = config['GraphDB']['graphdb']
2844
+ GRAPHDB_HOSTNAME = config['GraphDB']['graphdb_hostname']
2845
+ GRAPHDB_DATABASENAME = config['GraphDB']['graphdb_databasename']
2846
+ GRAPHDB_USER = config['GraphDB']['graphdb_user']
2847
+ GRAPHDB_PASSWORD = config['GraphDB']['graphdb_password']
2848
+ GRAPHDB_SCHEME = config['GraphDB']['graphdb_scheme']
2849
+ GRAPHDB_PORT = config['GraphDB']['graphdb_port']
2850
+ if GRAPHDB == '' or GRAPHDB_HOSTNAME == '' or GRAPHDB_DATABASENAME == '' \
2851
+ or GRAPHDB_USER == '' \
2852
+ or GRAPHDB_PASSWORD == '' or GRAPHDB_SCHEME == '' or GRAPHDB_PORT == '':
2853
+ print('Ricgraph initialization: error, one or more of the GraphDB parameters '
2854
+ + 'empty in Ricgraph ini file, exiting.')
2855
+ exit(1)
2856
+ GRAPHDB_URL = '{scheme}://{hostname}:{port}'.format(scheme=GRAPHDB_SCHEME,
2857
+ hostname=GRAPHDB_HOSTNAME,
2858
+ port=GRAPHDB_PORT)
2859
+ except KeyError:
2860
+ print('Ricgraph initialization: error, one or more of GraphDB parameters '
2861
+ + 'not found in Ricgraph ini file, exiting.')
2862
+ exit(1)
2863
+
2864
+ # Make sure DataFrames are printed with all columns on full width
2865
+ pandas.set_option('display.max_columns', None)
2866
+ pandas.set_option('display.width', 500)