folio-data-import 0.1.0__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.

Potentially problematic release.


This version of folio-data-import might be problematic. Click here for more details.

@@ -0,0 +1,724 @@
1
+ import argparse
2
+ import asyncio
3
+ import datetime
4
+ import getpass
5
+ import json
6
+ import os
7
+ import time
8
+ from datetime import datetime as dt
9
+ from pathlib import Path
10
+ from typing import Tuple
11
+
12
+ import aiofiles
13
+ import folioclient
14
+ import httpx
15
+ from aiofiles.threadpool.text import AsyncTextIOWrapper
16
+
17
+ try:
18
+ utc = datetime.UTC
19
+ except AttributeError:
20
+ import zoneinfo
21
+
22
+ utc = zoneinfo.ZoneInfo("UTC")
23
+
24
+
25
+ class UserImporter: # noqa: R0902
26
+ """
27
+ Class to import mod-user-import compatible user objects
28
+ (eg. from folio_migration_tools UserTransformer task)
29
+ from a JSON-lines file into FOLIO
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ folio_client: folioclient.FolioClient,
35
+ library_name: str,
36
+ user_file_path: Path,
37
+ batch_size: int,
38
+ limit_simultaneous_requests: asyncio.Semaphore,
39
+ logfile: AsyncTextIOWrapper,
40
+ errorfile: AsyncTextIOWrapper,
41
+ http_client: httpx.AsyncClient,
42
+ user_match_key: str = "externalSystemId",
43
+ only_update_present_fields: bool = False,
44
+ ) -> None:
45
+ self.limit_simultaneous_requests = limit_simultaneous_requests
46
+ self.batch_size = batch_size
47
+ self.folio_client: folioclient.FolioClient = folio_client
48
+ self.library_name: str = library_name
49
+ self.user_file_path: Path = user_file_path
50
+ self.patron_group_map: dict = self.build_ref_data_id_map(
51
+ self.folio_client, "/groups", "usergroups", "group"
52
+ )
53
+ self.address_type_map: dict = self.build_ref_data_id_map(
54
+ self.folio_client, "/addresstypes", "addressTypes", "addressType"
55
+ )
56
+ self.department_map: dict = self.build_ref_data_id_map(
57
+ self.folio_client, "/departments", "departments", "name"
58
+ )
59
+ self.logfile: AsyncTextIOWrapper = logfile
60
+ self.errorfile: AsyncTextIOWrapper = errorfile
61
+ self.http_client: httpx.AsyncClient = http_client
62
+ self.only_update_present_fields: bool = only_update_present_fields
63
+ self.match_key = user_match_key
64
+ self.lock: asyncio.Lock = asyncio.Lock()
65
+ self.logs: dict = {"created": 0, "updated": 0, "failed": 0}
66
+
67
+ @staticmethod
68
+ def build_ref_data_id_map(
69
+ folio_client: folioclient.FolioClient, endpoint: str, key: str, name: str
70
+ ) -> dict:
71
+ """
72
+ Builds a map of reference data IDs.
73
+
74
+ Args:
75
+ folio_client (folioclient.FolioClient): A FolioClient object.
76
+ endpoint (str): The endpoint to retrieve the reference data from.
77
+ key (str): The key to use as the map key.
78
+
79
+ Returns:
80
+ dict: A dictionary mapping reference data keys to their corresponding IDs.
81
+ """
82
+ return {x[name]: x["id"] for x in folio_client.folio_get_all(endpoint, key)}
83
+
84
+ async def do_import(self) -> None:
85
+ """
86
+ Main method to import users.
87
+
88
+ This method triggers the process of importing users by calling the `process_file` method.
89
+ """
90
+ await self.process_file()
91
+
92
+ async def get_existing_user(self, user_obj) -> dict:
93
+ """
94
+ Retrieves an existing user from FOLIO based on the provided user object.
95
+
96
+ Args:
97
+ user_obj: The user object containing the information to match against existing users.
98
+
99
+ Returns:
100
+ The existing user object if found, otherwise an empty dictionary.
101
+ """
102
+ match_key = "id" if ("id" in user_obj) else self.match_key
103
+ try:
104
+ existing_user = await self.http_client.get(
105
+ self.folio_client.okapi_url + "/users",
106
+ headers=self.folio_client.okapi_headers,
107
+ params={"query": f"{match_key}=={user_obj[match_key]}"},
108
+ )
109
+ existing_user.raise_for_status()
110
+ existing_user = existing_user.json().get("users", [])
111
+ existing_user = existing_user[0] if existing_user else {}
112
+ except httpx.HTTPError:
113
+ existing_user = {}
114
+ return existing_user
115
+
116
+ async def get_existing_rp(self, user_obj, existing_user) -> dict:
117
+ """
118
+ Retrieves the existing request preferences for a given user.
119
+
120
+ Args:
121
+ user_obj (dict): The user object.
122
+ existing_user (dict): The existing user object.
123
+
124
+ Returns:
125
+ dict: The existing request preferences for the user.
126
+ """
127
+ try:
128
+ existing_rp = await self.http_client.get(
129
+ self.folio_client.okapi_url
130
+ + "/request-preference-storage/request-preference",
131
+ headers=self.folio_client.okapi_headers,
132
+ params={
133
+ "query": f"userId=={existing_user.get('id', user_obj.get('id', ''))}"
134
+ },
135
+ )
136
+ existing_rp.raise_for_status()
137
+ existing_rp = existing_rp.json().get("requestPreferences", [])
138
+ existing_rp = existing_rp[0] if existing_rp else {}
139
+ except httpx.HTTPError:
140
+ existing_rp = {}
141
+ return existing_rp
142
+
143
+ async def get_existing_pu(self, user_obj, existing_user) -> dict:
144
+ """
145
+ Retrieves the existing permission user for a given user.
146
+
147
+ Args:
148
+ user_obj (dict): The user object.
149
+ existing_user (dict): The existing user object.
150
+
151
+ Returns:
152
+ dict: The existing permission user object.
153
+ """
154
+ try:
155
+ existing_pu = await self.http_client.get(
156
+ self.folio_client.okapi_url + "/perms/users",
157
+ headers=self.folio_client.okapi_headers,
158
+ params={
159
+ "query": f"userId=={existing_user.get('id', user_obj.get('id', ''))}"
160
+ },
161
+ )
162
+ existing_pu.raise_for_status()
163
+ existing_pu = existing_pu.json().get("permissionUsers", [])
164
+ existing_pu = existing_pu[0] if existing_pu else {}
165
+ except httpx.HTTPError:
166
+ existing_pu = {}
167
+ return existing_pu
168
+
169
+ async def map_address_types(self, user_obj, line_number) -> None:
170
+ """
171
+ Maps address type names in the user object to the corresponding ID in the address_type_map.
172
+
173
+ Args:
174
+ user_obj (dict): The user object containing personal information.
175
+ address_type_map (dict): A dictionary mapping address type names to their ID values.
176
+
177
+ Returns:
178
+ None
179
+
180
+ Raises:
181
+ KeyError: If an address type name in the user object is not found in address_type_map.
182
+
183
+ """
184
+ if "personal" in user_obj and "addresses" in user_obj["personal"]:
185
+ for address in user_obj["personal"]["addresses"]:
186
+ try:
187
+ address["addressTypeId"] = self.address_type_map[
188
+ address["addressTypeId"]
189
+ ]
190
+ except KeyError:
191
+ if address["addressTypeId"] not in self.address_type_map.values():
192
+ print(
193
+ f"Row {line_number}: Address type {address['addressTypeId']} not found"
194
+ f", removing address"
195
+ )
196
+ await self.logfile.write(
197
+ f"Row {line_number}: Address type {address['addressTypeId']} not found"
198
+ f", removing address\n"
199
+ )
200
+ del address
201
+ if len(user_obj["personal"]["addresses"]) == 0:
202
+ del user_obj["personal"]["addresses"]
203
+
204
+ async def map_patron_groups(self, user_obj, line_number) -> None:
205
+ """
206
+ Maps the patron group of a user object using the provided patron group map.
207
+
208
+ Args:
209
+ user_obj (dict): The user object to update.
210
+ patron_group_map (dict): A dictionary mapping patron group names.
211
+
212
+ Returns:
213
+ None
214
+ """
215
+ try:
216
+ user_obj["patronGroup"] = self.patron_group_map[user_obj["patronGroup"]]
217
+ except KeyError:
218
+ if user_obj["patronGroup"] not in self.patron_group_map.values():
219
+ print(
220
+ f"Row {line_number}: Patron group {user_obj['patronGroup']} not found, "
221
+ f"removing patron group"
222
+ )
223
+ await self.logfile.write(
224
+ f"Row {line_number}: Patron group {user_obj['patronGroup']} not found in, "
225
+ f"removing patron group\n"
226
+ )
227
+ del user_obj["patronGroup"]
228
+
229
+ async def map_departments(self, user_obj, line_number) -> None:
230
+ """
231
+ Maps the departments of a user object using the provided department map.
232
+
233
+ Args:
234
+ user_obj (dict): The user object to update.
235
+ department_map (dict): A dictionary mapping department names.
236
+
237
+ Returns:
238
+ None
239
+ """
240
+ mapped_departments = []
241
+ for department in user_obj["departments"]:
242
+ try:
243
+ mapped_departments.append(self.department_map[department])
244
+ except KeyError:
245
+ print(
246
+ f'Row {line_number}: Department "{department}" not found, '
247
+ f"excluding department from user"
248
+ )
249
+ await self.logfile.write(
250
+ f'Row {line_number}: Department "{department}" not found, '
251
+ f"excluding department from user\n"
252
+ )
253
+ user_obj["departments"] = mapped_departments
254
+
255
+ async def update_existing_user(self, user_obj, existing_user) -> Tuple[dict, dict]:
256
+ """
257
+ Updates an existing user with the provided user object.
258
+
259
+ Args:
260
+ user_obj (dict): The user object containing the updated user information.
261
+ existing_user (dict): The existing user object to be updated.
262
+
263
+ Returns:
264
+ tuple: A tuple containing the updated existing user object and the API response.
265
+
266
+ Raises:
267
+ None
268
+
269
+ """
270
+ if self.only_update_present_fields:
271
+ new_personal = user_obj.pop("personal", {})
272
+ existing_personal = existing_user.pop("personal", {})
273
+ existing_preferred_first_name = existing_personal.pop(
274
+ "preferredFirstName", ""
275
+ )
276
+ existing_addresses = existing_personal.get("addresses", [])
277
+ existing_user.update(user_obj)
278
+ existing_personal.update(new_personal)
279
+ if (
280
+ not existing_personal.get("preferredFirstName", "")
281
+ and existing_preferred_first_name
282
+ ):
283
+ existing_personal["preferredFirstName"] = existing_preferred_first_name
284
+ if not existing_personal.get("addresses", []):
285
+ existing_personal["addresses"] = existing_addresses
286
+ if existing_personal:
287
+ existing_user["personal"] = existing_personal
288
+ else:
289
+ existing_user.update(user_obj)
290
+ create_update_user = await self.http_client.put(
291
+ self.folio_client.okapi_url + f"/users/{existing_user['id']}",
292
+ headers=self.folio_client.okapi_headers,
293
+ json=existing_user,
294
+ )
295
+ return existing_user, create_update_user
296
+
297
+ async def create_new_user(self, user_obj) -> dict:
298
+ """
299
+ Creates a new user in the system.
300
+
301
+ Args:
302
+ user_obj (dict): A dictionary containing the user information.
303
+
304
+ Returns:
305
+ dict: A dictionary representing the response from the server.
306
+
307
+ Raises:
308
+ HTTPError: If the HTTP request to create the user fails.
309
+ """
310
+ response = await self.http_client.post(
311
+ self.folio_client.okapi_url + "/users",
312
+ headers=self.folio_client.okapi_headers,
313
+ json=user_obj,
314
+ )
315
+ response.raise_for_status()
316
+ async with self.lock:
317
+ self.logs["created"] += 1
318
+ return response.json()
319
+
320
+ async def create_or_update_user(self, user_obj, existing_user, line_number) -> dict:
321
+ """
322
+ Creates or updates a user based on the given user object and existing user.
323
+
324
+ Args:
325
+ user_obj (dict): The user object containing the user details.
326
+ existing_user (dict): The existing user object to be updated, if available.
327
+ logs (dict): A dictionary to keep track of the number of updates and failures.
328
+
329
+ Returns:
330
+ dict: The updated or created user object, or an empty dictionary an error occurs.
331
+ """
332
+ if existing_user:
333
+ existing_user, update_user = await self.update_existing_user(
334
+ user_obj, existing_user
335
+ )
336
+ try:
337
+ update_user.raise_for_status()
338
+ self.logs["updated"] += 1
339
+ return existing_user
340
+ except Exception as ee:
341
+ print(
342
+ f"Row {line_number}: User update failed: "
343
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
344
+ )
345
+ await self.logfile.write(
346
+ f"Row {line_number}: User update failed: "
347
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
348
+ )
349
+ await self.errorfile.write(
350
+ json.dumps(existing_user, ensure_ascii=False) + "\n"
351
+ )
352
+ self.logs["failed"] += 1
353
+ return {}
354
+ else:
355
+ try:
356
+ new_user = await self.create_new_user(user_obj)
357
+ return new_user
358
+ except Exception as ee:
359
+ print(
360
+ f"Row {line_number}: User creation failed: "
361
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
362
+ )
363
+ await self.logfile.write(
364
+ f"Row {line_number}: User creation failed: "
365
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
366
+ )
367
+ await self.errorfile.write(
368
+ json.dumps(user_obj, ensure_ascii=False) + "\n"
369
+ )
370
+ self.logs["failed"] += 1
371
+ return {}
372
+
373
+ async def process_user_obj(self, user: str) -> dict:
374
+ """
375
+ Process a user object.
376
+
377
+ Args:
378
+ user (str): The user data to be processed, as a json string.
379
+
380
+ Returns:
381
+ dict: The processed user object.
382
+
383
+ """
384
+ user_obj = json.loads(user)
385
+ user_obj["type"] = user_obj.get("type", "patron")
386
+ if "personal" in user_obj:
387
+ current_pref_contact = user_obj["personal"].get(
388
+ "preferredContactTypeId", ""
389
+ )
390
+ user_obj["personal"]["preferredContactTypeId"] = (
391
+ current_pref_contact
392
+ if current_pref_contact in ["001", "002", "003"]
393
+ else "002"
394
+ )
395
+ return user_obj
396
+
397
+ async def process_existing_user(self, user_obj) -> Tuple[dict, dict, dict, dict]:
398
+ """
399
+ Process an existing user.
400
+
401
+ Args:
402
+ user_obj (dict): The user object to process.
403
+
404
+ Returns:
405
+ tuple: A tuple containing the request preference object (rp_obj),
406
+ the existing user object, the existing request preference object (existing_rp),
407
+ and the existing PU object (existing_pu).
408
+ """
409
+ rp_obj = user_obj.pop("requestPreference", {})
410
+ existing_user = await self.get_existing_user(user_obj)
411
+ if existing_user:
412
+ existing_rp = await self.get_existing_rp(user_obj, existing_user)
413
+ existing_pu = await self.get_existing_pu(user_obj, existing_user)
414
+ else:
415
+ existing_rp = {}
416
+ existing_pu = {}
417
+ return rp_obj, existing_user, existing_rp, existing_pu
418
+
419
+ async def create_or_update_rp(self, rp_obj, existing_rp, new_user_obj):
420
+ """
421
+ Creates or updates a requet preference object based on the given parameters.
422
+
423
+ Args:
424
+ rp_obj (object): A new requet preference object.
425
+ existing_rp (object): The existing resource provider object, if it exists.
426
+ new_user_obj (object): The new user object.
427
+
428
+ Returns:
429
+ None
430
+ """
431
+ if existing_rp:
432
+ # print(existing_rp)
433
+ await self.update_existing_rp(rp_obj, existing_rp)
434
+ else:
435
+ # print(new_user_obj)
436
+ await self.create_new_rp(new_user_obj)
437
+
438
+ async def create_new_rp(self, new_user_obj):
439
+ """
440
+ Creates a new request preference for a user.
441
+
442
+ Args:
443
+ new_user_obj (dict): The user object containing the user's ID.
444
+
445
+ Raises:
446
+ HTTPError: If there is an error in the HTTP request.
447
+
448
+ Returns:
449
+ None
450
+ """
451
+ rp_obj = {"holdShelf": True, "delivery": False}
452
+ rp_obj["userId"] = new_user_obj["id"]
453
+ # print(rp_obj)
454
+ response = await self.http_client.post(
455
+ self.folio_client.okapi_url
456
+ + "/request-preference-storage/request-preference",
457
+ headers=self.folio_client.okapi_headers,
458
+ json=rp_obj,
459
+ )
460
+ response.raise_for_status()
461
+
462
+ async def update_existing_rp(self, rp_obj, existing_rp) -> None:
463
+ """
464
+ Updates an existing request preference with the provided request preference object.
465
+
466
+ Args:
467
+ rp_obj (dict): The request preference object containing the updated values.
468
+ existing_rp (dict): The existing request preference object to be updated.
469
+
470
+ Raises:
471
+ HTTPError: If the PUT request to update the request preference fails.
472
+
473
+ Returns:
474
+ None
475
+ """
476
+ existing_rp.update(rp_obj)
477
+ # print(existing_rp)
478
+ response = await self.http_client.put(
479
+ self.folio_client.okapi_url
480
+ + f"/request-preference-storage/request-preference/{existing_rp['id']}",
481
+ headers=self.folio_client.okapi_headers,
482
+ json=existing_rp,
483
+ )
484
+ response.raise_for_status()
485
+
486
+ async def create_perms_user(self, new_user_obj) -> None:
487
+ """
488
+ Creates a permissions user object for the given new user.
489
+
490
+ Args:
491
+ new_user_obj (dict): A dictionary containing the details of the new user.
492
+
493
+ Raises:
494
+ HTTPError: If there is an error while making the HTTP request.
495
+
496
+ Returns:
497
+ None
498
+ """
499
+ perms_user_obj = {"userId": new_user_obj["id"], "permissions": []}
500
+ response = await self.http_client.post(
501
+ self.folio_client.okapi_url + "/perms/users",
502
+ headers=self.folio_client.okapi_headers,
503
+ json=perms_user_obj,
504
+ )
505
+ response.raise_for_status()
506
+
507
+ async def process_line(
508
+ self,
509
+ user: str,
510
+ line_number: int,
511
+ ) -> None:
512
+ """
513
+ Process a single line of user data.
514
+
515
+ Args:
516
+ user (str): The user data to be processed.
517
+ logs (dict): A dictionary to store logs.
518
+
519
+ Returns:
520
+ None
521
+
522
+ Raises:
523
+ Any exceptions that occur during the processing.
524
+
525
+ """
526
+ async with self.limit_simultaneous_requests:
527
+ user_obj = await self.process_user_obj(user)
528
+ rp_obj, existing_user, existing_rp, existing_pu = (
529
+ await self.process_existing_user(user_obj)
530
+ )
531
+ await self.map_address_types(user_obj, line_number)
532
+ await self.map_patron_groups(user_obj, line_number)
533
+ await self.map_departments(user_obj, line_number)
534
+ new_user_obj = await self.create_or_update_user(
535
+ user_obj, existing_user, line_number
536
+ )
537
+ if new_user_obj:
538
+ try:
539
+ if existing_rp or rp_obj:
540
+ await self.create_or_update_rp(
541
+ rp_obj, existing_rp, new_user_obj
542
+ )
543
+ else:
544
+ print(
545
+ f"Row {line_number}: Creating default request preference object"
546
+ f" for {new_user_obj['id']}"
547
+ )
548
+ await self.logfile.write(
549
+ f"Row {line_number}: Creating default request preference object"
550
+ f" for {new_user_obj['id']}\n"
551
+ )
552
+ await self.create_new_rp(new_user_obj)
553
+ except Exception as ee: # noqa: W0718
554
+ rp_error_message = (
555
+ f"Row {line_number}: Error creating or updating request preferences for "
556
+ f"{new_user_obj['id']}: "
557
+ f"{str(getattr(getattr(ee, 'response', ee), 'text', str(ee)))}"
558
+ )
559
+ print(rp_error_message)
560
+ await self.logfile.write(rp_error_message + "\n")
561
+ if not existing_pu:
562
+ try:
563
+ await self.create_perms_user(new_user_obj)
564
+ except Exception as ee: # noqa: W0718
565
+ pu_error_message = (
566
+ f"Row {line_number}: Error creating permissionUser object for user: "
567
+ f"{new_user_obj['id']}: "
568
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
569
+ )
570
+ print(pu_error_message)
571
+ await self.logfile.write(pu_error_message + "\n")
572
+
573
+ async def process_file(self) -> None:
574
+ """
575
+ Process the user object file.
576
+ """
577
+ with open(self.user_file_path, "r", encoding="utf-8") as openfile:
578
+ tasks = []
579
+ for line_number, user in enumerate(openfile):
580
+ tasks.append(self.process_line(user, line_number))
581
+ if len(tasks) == self.batch_size:
582
+ start = time.time()
583
+ await asyncio.gather(*tasks)
584
+ duration = time.time() - start
585
+ async with self.lock:
586
+ message = (
587
+ f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
588
+ f"Batch of {self.batch_size} users processed in {duration:.2f} "
589
+ f"seconds. - Users created: {self.logs['created']} - Users updated: "
590
+ f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
591
+ )
592
+ print(message)
593
+ await self.logfile.write(message + "\n")
594
+ tasks = []
595
+ if tasks:
596
+ await asyncio.gather(*tasks)
597
+ async with self.lock:
598
+ message = (
599
+ f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
600
+ f"Batch of {self.batch_size} users processed in {duration:.2f} seconds. - "
601
+ f"Users created: {self.logs['created']} - Users updated: "
602
+ f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
603
+ )
604
+ print(message)
605
+ await self.logfile.write(message + "\n")
606
+
607
+
608
+ async def main() -> None:
609
+ """
610
+ Entry point of the user import script.
611
+
612
+ Parses command line arguments, initializes necessary objects, and starts the import process.
613
+
614
+ Args:
615
+ --tenant_id (str): The tenant id.
616
+ --library_name (str): The name of the library.
617
+ --username (str): The FOLIO username.
618
+ --okapi_url (str): The Okapi URL.
619
+ --user_file_path (str): The path to the user file.
620
+ --limit_async_requests (int): Limit how many http requests can be made at once. Default 10.
621
+ --batch_size (int): How many records to process before logging statistics. Default 250.
622
+ --folio_password (str): The FOLIO password.
623
+ --user_match_key (str): The key to use to match users. Default "externalSystemId".
624
+
625
+ Raises:
626
+ Exception: If an unknown error occurs during the import process.
627
+
628
+ Returns:
629
+ None
630
+ """
631
+ parser = argparse.ArgumentParser()
632
+ parser.add_argument("--tenant_id", help="The tenant id")
633
+ parser.add_argument("--library_name", help="The name of the library")
634
+ parser.add_argument("--username", help="The FOLIO username")
635
+ parser.add_argument("--okapi_url", help="The Okapi URL")
636
+ parser.add_argument("--user_file_path", help="The path to the user file")
637
+ parser.add_argument(
638
+ "--limit_async_requests",
639
+ help="Limit how many http requests can be made at once",
640
+ type=int,
641
+ default=10,
642
+ )
643
+ parser.add_argument(
644
+ "--batch_size",
645
+ help="How many user records to process before logging statistics",
646
+ type=int,
647
+ default=250,
648
+ )
649
+ parser.add_argument("--folio_password", help="The FOLIO password")
650
+ parser.add_argument(
651
+ "--user_match_key",
652
+ help="The key to use to match users",
653
+ choices=["externalSystemId", "barcode", "username"],
654
+ default="externalSystemId",
655
+ )
656
+ parser.add_argument(
657
+ "--update_only_present_fields",
658
+ help="Only update fields that are present in the user object",
659
+ action="store_true",
660
+ )
661
+ args = parser.parse_args()
662
+
663
+ library_name = args.library_name
664
+
665
+ # Semaphore to limit the number of async HTTP requests active at any given time
666
+ limit_async_requests = asyncio.Semaphore(args.limit_async_requests)
667
+ batch_size = args.batch_size
668
+
669
+ folio_client = folioclient.FolioClient(
670
+ args.okapi_url,
671
+ args.tenant_id,
672
+ args.username,
673
+ args.folio_password
674
+ or os.environ.get("FOLIO_PASS", "")
675
+ or getpass.getpass("Enter your FOLIO password: "),
676
+ )
677
+ user_file_path = Path(args.user_file_path)
678
+ log_file_path = (
679
+ user_file_path.parent.parent
680
+ / "reports"
681
+ / f"log_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.log"
682
+ )
683
+ error_file_path = (
684
+ user_file_path.parent
685
+ / f"failed_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.txt"
686
+ )
687
+ async with aiofiles.open(
688
+ log_file_path,
689
+ "w",
690
+ ) as logfile, aiofiles.open(
691
+ error_file_path, "w"
692
+ ) as errorfile, httpx.AsyncClient(timeout=None) as http_client:
693
+ try:
694
+ importer = UserImporter(
695
+ folio_client,
696
+ library_name,
697
+ user_file_path,
698
+ batch_size,
699
+ limit_async_requests,
700
+ logfile,
701
+ errorfile,
702
+ http_client,
703
+ args.user_match_key,
704
+ args.update_only_present_fields,
705
+ )
706
+ await importer.do_import()
707
+ except Exception as ee:
708
+ print(f"An unknown error occurred: {ee}")
709
+ await logfile.write(f"An error occurred {ee}\n")
710
+ raise ee
711
+
712
+
713
+ def sync_main() -> None:
714
+ """
715
+ Synchronous version of the main function.
716
+
717
+ This function is used to run the main function in a synchronous context.
718
+ """
719
+ asyncio.run(main())
720
+
721
+
722
+ # Run the main function
723
+ if __name__ == "__main__":
724
+ asyncio.run(main())