folio-migration-tools 1.9.7__tar.gz → 1.9.9__tar.gz

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.
Files changed (66) hide show
  1. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/PKG-INFO +6 -5
  2. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/pyproject.toml +2 -2
  3. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/batch_poster.py +156 -29
  4. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/LICENSE +0 -0
  5. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/README.md +0 -0
  6. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/__init__.py +0 -0
  7. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/__main__.py +0 -0
  8. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/circulation_helper.py +0 -0
  9. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/colors.py +0 -0
  10. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/config_file_load.py +0 -0
  11. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/custom_dict.py +0 -0
  12. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/custom_exceptions.py +0 -0
  13. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/extradata_writer.py +0 -0
  14. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/folder_structure.py +0 -0
  15. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/helper.py +0 -0
  16. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/holdings_helper.py +0 -0
  17. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/i18n_config.py +0 -0
  18. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/library_configuration.py +0 -0
  19. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapper_base.py +0 -0
  20. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/__init__.py +0 -0
  21. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/courses_mapper.py +0 -0
  22. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/holdings_mapper.py +0 -0
  23. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/item_mapper.py +0 -0
  24. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/manual_fee_fines_mapper.py +0 -0
  25. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/mapping_file_mapper_base.py +0 -0
  26. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/notes_mapper.py +0 -0
  27. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/order_mapper.py +0 -0
  28. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/organization_mapper.py +0 -0
  29. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/ref_data_mapping.py +0 -0
  30. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/mapping_file_transformation/user_mapper.py +0 -0
  31. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/__init__.py +0 -0
  32. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/conditions.py +0 -0
  33. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/holdings_statementsparser.py +0 -0
  34. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/hrid_handler.py +0 -0
  35. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/loc_language_codes.xml +0 -0
  36. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/marc_file_processor.py +0 -0
  37. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/marc_reader_wrapper.py +0 -0
  38. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/rules_mapper_authorities.py +0 -0
  39. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/rules_mapper_base.py +0 -0
  40. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/rules_mapper_bibs.py +0 -0
  41. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/marc_rules_transformation/rules_mapper_holdings.py +0 -0
  42. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_report.py +0 -0
  43. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/__init__.py +0 -0
  44. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/authority_transformer.py +0 -0
  45. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/bibs_transformer.py +0 -0
  46. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/courses_migrator.py +0 -0
  47. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/holdings_csv_transformer.py +0 -0
  48. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/holdings_marc_transformer.py +0 -0
  49. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/items_transformer.py +0 -0
  50. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/loans_migrator.py +0 -0
  51. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/manual_fee_fines_transformer.py +0 -0
  52. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/migration_task_base.py +0 -0
  53. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/orders_transformer.py +0 -0
  54. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/organization_transformer.py +0 -0
  55. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/requests_migrator.py +0 -0
  56. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/reserves_migrator.py +0 -0
  57. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/migration_tasks/user_transformer.py +0 -0
  58. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/task_configuration.py +0 -0
  59. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/test_infrastructure/__init__.py +0 -0
  60. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/test_infrastructure/mocked_classes.py +0 -0
  61. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/transaction_migration/__init__.py +0 -0
  62. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/transaction_migration/legacy_loan.py +0 -0
  63. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/transaction_migration/legacy_request.py +0 -0
  64. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/transaction_migration/legacy_reserve.py +0 -0
  65. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/transaction_migration/transaction_result.py +0 -0
  66. {folio_migration_tools-1.9.7 → folio_migration_tools-1.9.9}/src/folio_migration_tools/translations/en.json +0 -0
@@ -1,24 +1,25 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: folio_migration_tools
3
- Version: 1.9.7
3
+ Version: 1.9.9
4
4
  Summary: A tool allowing you to migrate data from legacy ILS:s (Library systems) into FOLIO LSP
5
- License: MIT
5
+ License-Expression: MIT
6
+ License-File: LICENSE
6
7
  Keywords: FOLIO,ILS,LSP,Library Systems,MARC21,Library data
7
8
  Author: Theodor Tolstoy
8
9
  Author-email: github.teddes@tolstoy.se
9
10
  Requires-Python: >=3.10,<4.0
10
- Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
16
17
  Provides-Extra: docs
17
18
  Requires-Dist: argparse-prompt (>=0.0.5,<0.0.6)
18
19
  Requires-Dist: art (>=6.5,<7.0)
19
20
  Requires-Dist: deepdiff (>=6.2.3,<7.0.0)
20
21
  Requires-Dist: defusedxml (>=0.7.1,<0.8.0)
21
- Requires-Dist: folio-data-import (>=0.3.2,<0.4.0)
22
+ Requires-Dist: folio-data-import (>=0.4.1)
22
23
  Requires-Dist: folio-uuid (>=1.0.0,<2.0.0)
23
24
  Requires-Dist: folioclient (>=0.70.1,<0.71.0)
24
25
  Requires-Dist: pyaml (>=21.10.1,<22.0.0)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "folio_migration_tools"
3
- version = "1.9.7"
3
+ version = "1.9.9"
4
4
  description = "A tool allowing you to migrate data from legacy ILS:s (Library systems) into FOLIO LSP"
5
5
  authors = [
6
6
  {name = "Theodor Tolstoy", email = "github.teddes@tolstoy.se"},
@@ -56,7 +56,7 @@ deepdiff = "^6.2.3"
56
56
  pyaml = "^21.10.1"
57
57
  python-i18n = "^0.3.9"
58
58
  art = "^6.5"
59
- folio-data-import = "^0.3.2"
59
+ folio-data-import = ">=0.4.1"
60
60
 
61
61
  [tool.poetry.group.dev.dependencies]
62
62
  pytest = "^7.1.3"
@@ -2,6 +2,7 @@ import asyncio
2
2
  import copy
3
3
  import json
4
4
  import logging
5
+ import re
5
6
  import sys
6
7
  import time
7
8
  import traceback
@@ -181,6 +182,19 @@ class BatchPoster(MigrationTaskBase):
181
182
  ),
182
183
  ),
183
184
  ] = True
185
+ patch_existing_records: Annotated[bool, Field(
186
+ title="Patch existing records",
187
+ description=(
188
+ "Toggles whether or not to patch existing records "
189
+ "during the upsert process. Defaults to False"
190
+ ),
191
+ )] = False
192
+ patch_paths: Annotated[List[str], Field(
193
+ title="Patch paths",
194
+ description=(
195
+ "A list of fields in JSON Path notation to patch during the upsert process (leave off the $). If empty, all fields will be patched. Examples: ['statisticalCodeIds', 'administrativeNotes', 'instanceStatusId']"
196
+ ),
197
+ )] = []
184
198
 
185
199
  task_configuration: TaskConfiguration
186
200
 
@@ -368,16 +382,33 @@ class BatchPoster(MigrationTaskBase):
368
382
  if record["id"] in existing_records:
369
383
  self.prepare_record_for_upsert(record, existing_records[record["id"]])
370
384
 
371
- def handle_source_marc(self, new_record: dict, existing_record: dict):
385
+ def patch_record(self, new_record: dict, existing_record: dict, patch_paths: List[str]):
386
+ """
387
+ Updates new_record with values from existing_record according to patch_paths.
388
+
389
+ Args:
390
+ new_record (dict): The new record to be updated.
391
+ existing_record (dict): The existing record to patch from.
392
+ patch_paths (List[str]): List of fields in JSON Path notation (e.g., ['statisticalCodeIds', 'administrativeNotes', 'instanceStatusId']) to patch during the upsert process. If empty, all fields will be patched.
393
+ """
372
394
  updates = {}
373
395
  updates.update(existing_record)
374
- self.handle_upsert_for_administrative_notes(updates)
375
- self.handle_upsert_for_statistical_codes(updates)
376
- keep_new = {k: v for k, v in new_record.items() if k in ["statisticalCodeIds", "administrativeNotes"]}
396
+ keep_existing = {}
397
+ self.handle_upsert_for_administrative_notes(updates, keep_existing)
398
+ self.handle_upsert_for_statistical_codes(updates, keep_existing)
399
+ if not patch_paths:
400
+ keep_new = new_record
401
+ else:
402
+ keep_new = extract_paths(new_record, patch_paths)
377
403
  if "instanceStatusId" in new_record:
378
404
  updates["instanceStatusId"] = new_record["instanceStatusId"]
379
- for k, v in keep_new.items():
380
- updates[k] = list(dict.fromkeys(updates.get(k, []) + v))
405
+ deep_update(updates, keep_new)
406
+ for key, value in keep_existing.items():
407
+ if isinstance(value, list) and key in keep_new:
408
+ updates[key] = list(dict.fromkeys(updates.get(key, []) + value))
409
+ elif key not in keep_new:
410
+ updates[key] = value
411
+ new_record.clear()
381
412
  new_record.update(updates)
382
413
 
383
414
  @staticmethod
@@ -393,21 +424,29 @@ class BatchPoster(MigrationTaskBase):
393
424
  response.text,
394
425
  )
395
426
 
396
- def handle_upsert_for_statistical_codes(self, updates: dict):
427
+ def handle_upsert_for_statistical_codes(self, updates: dict, keep_existing: dict):
397
428
  if not self.task_configuration.preserve_statistical_codes:
398
- updates.pop("statisticalCodeIds", None)
429
+ updates["statisticalCodeIds"] = []
430
+ keep_existing["statisticalCodeIds"] = []
431
+ else:
432
+ keep_existing["statisticalCodeIds"] = updates.pop("statisticalCodeIds", [])
433
+ updates["statisticalCodeIds"] = []
399
434
 
400
- def handle_upsert_for_administrative_notes(self, updates: dict):
435
+ def handle_upsert_for_administrative_notes(self, updates: dict, keep_existing: dict):
401
436
  if not self.task_configuration.preserve_administrative_notes:
402
- updates.pop("administrativeNotes", None)
437
+ updates["administrativeNotes"] = []
438
+ keep_existing["administrativeNotes"] = []
439
+ else:
440
+ keep_existing["administrativeNotes"] = updates.pop("administrativeNotes", [])
441
+ updates["administrativeNotes"] = []
403
442
 
404
- def handle_upsert_for_temporary_locations(self, updates: dict):
405
- if not self.task_configuration.preserve_temporary_locations:
406
- updates.pop("temporaryLocationId", None)
443
+ def handle_upsert_for_temporary_locations(self, updates: dict, keep_existing: dict):
444
+ if self.task_configuration.preserve_temporary_locations:
445
+ keep_existing["temporaryLocationId"] = updates.pop("temporaryLocationId", None)
407
446
 
408
- def handle_upsert_for_temporary_loan_types(self, updates: dict):
409
- if not self.task_configuration.preserve_temporary_loan_types:
410
- updates.pop("temporaryLoanTypeId", None)
447
+ def handle_upsert_for_temporary_loan_types(self, updates: dict, keep_existing: dict):
448
+ if self.task_configuration.preserve_temporary_loan_types:
449
+ keep_existing["temporaryLoanTypeId"] = updates.pop("temporaryLoanTypeId", None)
411
450
 
412
451
  def keep_existing_fields(self, updates: dict, existing_record: dict):
413
452
  keep_existing_fields = ["hrid", "lastCheckIn"]
@@ -419,25 +458,31 @@ class BatchPoster(MigrationTaskBase):
419
458
 
420
459
  def prepare_record_for_upsert(self, new_record: dict, existing_record: dict):
421
460
  if "source" in existing_record and "MARC" in existing_record["source"]:
422
- self.handle_source_marc(new_record, existing_record)
461
+ if self.task_configuration.patch_paths:
462
+ logging.debug(
463
+ "Record %s is a MARC record, patch_paths will be ignored",
464
+ existing_record["id"],
465
+ )
466
+ self.patch_record(new_record, existing_record, ["statisticalCodeIds", "administrativeNotes", "instanceStatusId"])
467
+ elif self.task_configuration.patch_existing_records:
468
+ self.patch_record(new_record, existing_record, self.task_configuration.patch_paths)
423
469
  else:
424
470
  updates = {
425
471
  "_version": existing_record["_version"],
426
472
  }
427
473
  self.keep_existing_fields(updates, existing_record)
428
474
  keep_new = {k: v for k, v in new_record.items() if k in ["statisticalCodeIds", "administrativeNotes"]}
429
- self.handle_upsert_for_statistical_codes(existing_record)
430
- self.handle_upsert_for_administrative_notes(existing_record)
431
- self.handle_upsert_for_temporary_locations(existing_record)
432
- self.handle_upsert_for_temporary_loan_types(existing_record)
433
- for k, v in keep_new.items():
434
- updates[k] = list(dict.fromkeys(existing_record.get(k, []) + v))
435
- for key in [
436
- "temporaryLocationId",
437
- "temporaryLoanTypeId",
438
- ]:
439
- if key in existing_record:
440
- updates[key] = existing_record[key]
475
+ keep_existing = {}
476
+ self.handle_upsert_for_statistical_codes(existing_record, keep_existing)
477
+ self.handle_upsert_for_administrative_notes(existing_record, keep_existing)
478
+ self.handle_upsert_for_temporary_locations(existing_record, keep_existing)
479
+ self.handle_upsert_for_temporary_loan_types(existing_record, keep_existing)
480
+ for k, v in keep_existing.items():
481
+ if isinstance(v, list) and k in keep_new:
482
+ keep_new[k] = list(dict.fromkeys(v + keep_new.get(k, [])))
483
+ elif k not in keep_new:
484
+ keep_new[k] = v
485
+ updates.update(keep_new)
441
486
  new_record.update(updates)
442
487
 
443
488
  async def get_with_retry(self, client: httpx.AsyncClient, url: str, params=None):
@@ -1076,3 +1121,85 @@ def get_req_size(response: httpx.Response):
1076
1121
  size += "\r\n".join(f"{k}{v}" for k, v in response.request.headers.items())
1077
1122
  size += response.request.content.decode("utf-8") or ""
1078
1123
  return get_human_readable(len(size.encode("utf-8")))
1124
+
1125
+ def parse_path(path):
1126
+ """
1127
+ Parses a path like 'foo.bar[0].baz' into ['foo', 'bar', 0, 'baz']
1128
+ """
1129
+ tokens = []
1130
+ # Split by dot, then extract indices
1131
+ for part in path.split('.'):
1132
+ # Find all [index] parts
1133
+ matches = re.findall(r'([^\[\]]+)|\[(\d+)\]', part)
1134
+ for name, idx in matches:
1135
+ if name:
1136
+ tokens.append(name)
1137
+ if idx:
1138
+ tokens.append(int(idx))
1139
+ return tokens
1140
+
1141
+ def get_by_path(data, path):
1142
+ keys = parse_path(path)
1143
+ for key in keys:
1144
+ data = data[key]
1145
+ return data
1146
+
1147
+ def set_by_path(data, path, value):
1148
+ keys = parse_path(path)
1149
+ for i, key in enumerate(keys[:-1]):
1150
+ next_key = keys[i + 1]
1151
+ if isinstance(key, int):
1152
+ while len(data) <= key:
1153
+ data.append({} if not isinstance(next_key, int) else [])
1154
+ data = data[key]
1155
+ else:
1156
+ if key not in data or not isinstance(data[key], (dict, list)):
1157
+ data[key] = {} if not isinstance(next_key, int) else []
1158
+ data = data[key]
1159
+ last_key = keys[-1]
1160
+ if isinstance(last_key, int):
1161
+ while len(data) <= last_key:
1162
+ data.append(None)
1163
+ data[last_key] = value
1164
+ else:
1165
+ data[last_key] = value
1166
+
1167
+ def extract_paths(data, paths):
1168
+ result = {}
1169
+ for path in paths:
1170
+ try:
1171
+ value = get_by_path(data, path)
1172
+ set_by_path(result, path, value)
1173
+ except KeyError:
1174
+ continue
1175
+ return result
1176
+
1177
+ def deep_update(target, patch):
1178
+ """
1179
+ Recursively update target dict/list with values from patch dict/list.
1180
+ For lists, only non-None values in patch are merged into target.
1181
+ """
1182
+ if isinstance(patch, dict):
1183
+ for k, v in patch.items():
1184
+ if (
1185
+ k in target
1186
+ and isinstance(target[k], (dict, list))
1187
+ and isinstance(v, (dict, list))
1188
+ ):
1189
+ deep_update(target[k], v)
1190
+ else:
1191
+ target[k] = v
1192
+ elif isinstance(patch, list):
1193
+ for i, v in enumerate(patch):
1194
+ if v is None:
1195
+ continue # Skip None values, leave target unchanged
1196
+ if i < len(target):
1197
+ if isinstance(target[i], (dict, list)) and isinstance(v, (dict, list)):
1198
+ deep_update(target[i], v)
1199
+ else:
1200
+ target[i] = v
1201
+ else:
1202
+ # Only append if not None
1203
+ target.append(v)
1204
+ else:
1205
+ return patch