django-bulk-hooks 0.2.93__tar.gz → 0.2.103__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 (27) hide show
  1. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/dispatcher.py +90 -24
  3. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/bulk_executor.py +23 -0
  4. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/field_utils.py +9 -3
  5. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/mti_handler.py +9 -0
  6. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/pyproject.toml +1 -1
  7. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/LICENSE +0 -0
  8. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/README.md +0 -0
  9. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/models.py +0 -0
  21. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/__init__.py +0 -0
  22. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/analyzer.py +0 -0
  23. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/coordinator.py +0 -0
  24. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.93 → django_bulk_hooks-0.2.103}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.93
3
+ Version: 0.2.103
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -101,7 +101,9 @@ class HookDispatcher:
101
101
  hooks = self.registry.get_hooks(changeset.model_cls, event)
102
102
 
103
103
  logger.debug(f"🧵 DISPATCH: changeset.model_cls={changeset.model_cls.__name__}, event={event}")
104
- logger.debug(f"🎣 HOOKS_FOUND: {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}: {[f'{h[0].__name__}.{h[1]}' for h in hooks]}")
104
+ logger.debug(
105
+ f"🎣 HOOKS_FOUND: {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}: {[f'{h[0].__name__}.{h[1]}' for h in hooks]}"
106
+ )
105
107
 
106
108
  if not hooks:
107
109
  return
@@ -125,11 +127,11 @@ class HookDispatcher:
125
127
  sorted_record_ids = tuple(record_ids)
126
128
 
127
129
  # Include changeset model and operation details to make the key more specific
128
- operation_meta = getattr(changeset, 'operation_meta', {}) or {}
129
- operation_type = getattr(changeset, 'operation_type', 'unknown')
130
+ operation_meta = getattr(changeset, "operation_meta", {}) or {}
131
+ operation_type = getattr(changeset, "operation_type", "unknown")
130
132
 
131
133
  # Include update_kwargs if present to distinguish different queryset operations
132
- update_kwargs = operation_meta.get('update_kwargs', {})
134
+ update_kwargs = operation_meta.get("update_kwargs", {})
133
135
  if update_kwargs:
134
136
  try:
135
137
  # Convert to a hashable representation
@@ -143,7 +145,7 @@ class HookDispatcher:
143
145
  operation_key = (event, changeset.model_cls.__name__, operation_type, sorted_record_ids, update_kwargs_key)
144
146
 
145
147
  # Track executed hooks to prevent duplicates in MTI inheritance chains
146
- if not hasattr(self, '_executed_hooks'):
148
+ if not hasattr(self, "_executed_hooks"):
147
149
  self._executed_hooks = set()
148
150
 
149
151
  # Filter out hooks that have already been executed for this operation
@@ -224,7 +226,7 @@ class HookDispatcher:
224
226
  )
225
227
 
226
228
  # Mark that relationships have been preloaded to avoid duplicate condition preloading
227
- changeset.operation_meta['relationships_preloaded'] = True
229
+ changeset.operation_meta["relationships_preloaded"] = True
228
230
  logger.debug(f"🔗 @select_related: Preloaded relationships for {handler_cls.__name__}.{method_name}")
229
231
 
230
232
  except Exception as e:
@@ -239,9 +241,11 @@ class HookDispatcher:
239
241
  # NOW condition evaluation is safe - relationships are preloaded
240
242
  if condition:
241
243
  # Skip per-hook preloading if relationships were already preloaded upfront
242
- if not changeset.operation_meta.get('relationships_preloaded', False):
244
+ if not changeset.operation_meta.get("relationships_preloaded", False):
243
245
  condition_relationships = self._extract_condition_relationships(condition, changeset.model_cls)
244
- logger.info(f"🔍 CONDITION: {handler_cls.__name__}.{method_name} has condition, extracted relationships: {condition_relationships}")
246
+ logger.info(
247
+ f"🔍 CONDITION: {handler_cls.__name__}.{method_name} has condition, extracted relationships: {condition_relationships}"
248
+ )
245
249
  if condition_relationships:
246
250
  logger.info(f"🔗 PRELOADING: Preloading condition relationships for {len(changeset.changes)} records")
247
251
  self._preload_condition_relationships(changeset, condition_relationships)
@@ -312,20 +316,20 @@ class HookDispatcher:
312
316
  relationships = set()
313
317
 
314
318
  # Guard against Mock objects and non-condition objects
315
- if not hasattr(condition, 'check') or hasattr(condition, '_mock_name'):
319
+ if not hasattr(condition, "check") or hasattr(condition, "_mock_name"):
316
320
  return relationships
317
321
 
318
322
  # Handle different condition types
319
- if hasattr(condition, 'field'):
323
+ if hasattr(condition, "field"):
320
324
  # Extract relationships from field path (e.g., "status__value" -> "status")
321
325
  field_path = condition.field
322
326
  if isinstance(field_path, str):
323
- if '__' in field_path:
327
+ if "__" in field_path:
324
328
  # Take the first part before __ (the relationship to preload)
325
- rel_field = field_path.split('__')[0]
329
+ rel_field = field_path.split("__")[0]
326
330
 
327
331
  # Normalize FK field names: business_id -> business
328
- if rel_field.endswith('_id'):
332
+ if rel_field.endswith("_id"):
329
333
  potential_field_name = rel_field[:-3] # Remove '_id'
330
334
  if self._is_relationship_field(model_cls, potential_field_name):
331
335
  rel_field = potential_field_name
@@ -336,7 +340,7 @@ class HookDispatcher:
336
340
  rel_field = field_path
337
341
 
338
342
  # Normalize FK field names: business_id -> business
339
- if rel_field.endswith('_id'):
343
+ if rel_field.endswith("_id"):
340
344
  potential_field_name = rel_field[:-3] # Remove '_id'
341
345
  if self._is_relationship_field(model_cls, potential_field_name):
342
346
  rel_field = potential_field_name
@@ -346,12 +350,12 @@ class HookDispatcher:
346
350
  relationships.add(rel_field)
347
351
 
348
352
  # Handle composite conditions (AndCondition, OrCondition)
349
- if hasattr(condition, 'cond1') and hasattr(condition, 'cond2'):
353
+ if hasattr(condition, "cond1") and hasattr(condition, "cond2"):
350
354
  relationships.update(self._extract_condition_relationships(condition.cond1, model_cls))
351
355
  relationships.update(self._extract_condition_relationships(condition.cond2, model_cls))
352
356
 
353
357
  # Handle NotCondition
354
- if hasattr(condition, 'cond'):
358
+ if hasattr(condition, "cond"):
355
359
  relationships.update(self._extract_condition_relationships(condition.cond, model_cls))
356
360
 
357
361
  return relationships
@@ -403,9 +407,40 @@ class HookDispatcher:
403
407
  preloaded_obj = preloaded[obj.pk]
404
408
  for rel in relationship_list:
405
409
  if hasattr(preloaded_obj, rel):
410
+ # Preserve FK _id values in __dict__ before setattr (MTI fix)
411
+ id_field_name = f"{rel}_id"
412
+ field_was_in_dict = id_field_name in obj.__dict__
413
+ preserved_id = obj.__dict__.get(id_field_name) if field_was_in_dict else None
414
+
415
+ logger.debug("🔄 PRESERVE_FK_NEW: obj.pk=%s, %s in __dict__=%s, preserved=%s",
416
+ obj.pk, id_field_name, field_was_in_dict, preserved_id)
417
+
406
418
  setattr(obj, rel, getattr(preloaded_obj, rel))
407
419
 
420
+ after_setattr = obj.__dict__.get(id_field_name, "NOT_IN_DICT")
421
+ logger.debug("🔄 AFTER_SETATTR_NEW: obj.pk=%s, %s=%s (was %s)",
422
+ obj.pk, id_field_name, after_setattr, preserved_id)
423
+
424
+ # Restore FK _id if it was in __dict__ (prevents Django descriptor from clearing it)
425
+ # This includes restoring None if that's what was explicitly set
426
+ if field_was_in_dict:
427
+ obj.__dict__[id_field_name] = preserved_id
428
+ # Also clear the relationship from fields_cache to prevent Django from
429
+ # returning the cached object when preserved_id is None
430
+ if preserved_id is None and hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
431
+ if rel in obj._state.fields_cache:
432
+ obj._state.fields_cache.pop(rel, None)
433
+ logger.debug("🔄 CLEARED_CACHE: obj.pk=%s, cleared '%s' from fields_cache",
434
+ obj.pk, rel)
435
+ logger.debug("🔄 RESTORED_FK_NEW: obj.pk=%s, %s=%s",
436
+ obj.pk, id_field_name, obj.__dict__.get(id_field_name))
437
+
408
438
  # Update old_records with preloaded relationships
439
+ # NOTE: We do NOT preserve/restore FK _id values for old_records because:
440
+ # 1. old_records represent the "before" state for hook conditions
441
+ # 2. They should reflect database values, not in-memory user changes
442
+ # 3. When new_records and old_records share the same object instances,
443
+ # restoring DB values here would overwrite user's in-memory changes
409
444
  if changeset.old_records:
410
445
  for obj in changeset.old_records:
411
446
  if obj.pk and obj.pk in preloaded:
@@ -414,6 +449,13 @@ class HookDispatcher:
414
449
  if hasattr(preloaded_obj, rel):
415
450
  setattr(obj, rel, getattr(preloaded_obj, rel))
416
451
 
452
+ # Log final state after preloading
453
+ if changeset.new_records:
454
+ for obj in changeset.new_records:
455
+ if "business_id" in obj.__dict__:
456
+ logger.debug("🔄 FINAL_STATE_NEW: obj.pk=%s, business_id=%s",
457
+ obj.pk, obj.__dict__.get("business_id"))
458
+
417
459
  # Handle unsaved new_records by preloading their FK targets (bulk query to avoid N+1)
418
460
  if changeset.new_records:
419
461
  # Collect FK IDs for each relationship from unsaved records
@@ -422,8 +464,8 @@ class HookDispatcher:
422
464
  for obj in changeset.new_records:
423
465
  if obj.pk is None: # Unsaved object
424
466
  for rel in relationship_list:
425
- if hasattr(obj, f'{rel}_id'):
426
- rel_id = getattr(obj, f'{rel}_id')
467
+ if hasattr(obj, f"{rel}_id"):
468
+ rel_id = getattr(obj, f"{rel}_id")
427
469
  if rel_id:
428
470
  field_ids_map[rel].add(rel_id)
429
471
 
@@ -433,7 +475,7 @@ class HookDispatcher:
433
475
  if not ids:
434
476
  continue
435
477
  try:
436
- rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
478
+ rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, "model")
437
479
  field_objects_map[rel] = rel_model.objects.in_bulk(ids)
438
480
  except Exception:
439
481
  field_objects_map[rel] = {}
@@ -442,12 +484,26 @@ class HookDispatcher:
442
484
  for obj in changeset.new_records:
443
485
  if obj.pk is None: # Unsaved object
444
486
  for rel in relationship_list:
445
- rel_id = getattr(obj, f'{rel}_id', None)
487
+ rel_id = getattr(obj, f"{rel}_id", None)
446
488
  if rel_id and rel in field_objects_map:
447
489
  rel_obj = field_objects_map[rel].get(rel_id)
448
490
  if rel_obj:
491
+ # Preserve FK _id value before setattr (MTI/upsert fix)
492
+ id_field_name = f"{rel}_id"
493
+ field_was_in_dict = id_field_name in obj.__dict__
494
+ preserved_id = obj.__dict__.get(id_field_name) if field_was_in_dict else None
495
+
496
+ logger.debug("🔄 PRESERVE_FK_UNSAVED: obj.pk=%s, %s in __dict__=%s, preserved=%s",
497
+ obj.pk, id_field_name, field_was_in_dict, preserved_id)
498
+
449
499
  setattr(obj, rel, rel_obj)
450
500
 
501
+ # Restore FK _id if it was in __dict__ (prevents Django descriptor from clearing it)
502
+ if field_was_in_dict and preserved_id is not None:
503
+ obj.__dict__[id_field_name] = preserved_id
504
+ logger.debug("🔄 RESTORED_FK_UNSAVED: obj.pk=%s, %s=%s",
505
+ obj.pk, id_field_name, obj.__dict__.get(id_field_name))
506
+
451
507
  def _preload_select_related_for_before_create(self, changeset, select_related_fields):
452
508
  """
453
509
  Explicit bulk preloading for @select_related on BEFORE_CREATE hooks.
@@ -462,7 +518,7 @@ class HookDispatcher:
462
518
  select_related_fields: List of field names to preload (e.g., ['financial_account'])
463
519
  """
464
520
  # Ensure select_related_fields is actually iterable (not a Mock in tests)
465
- if not select_related_fields or not changeset.new_records or not hasattr(select_related_fields, '__iter__'):
521
+ if not select_related_fields or not changeset.new_records or not hasattr(select_related_fields, "__iter__"):
466
522
  return
467
523
 
468
524
  logger.info(f"🔗 BULK PRELOAD: Preloading {select_related_fields} for {len(changeset.new_records)} unsaved records")
@@ -472,7 +528,7 @@ class HookDispatcher:
472
528
 
473
529
  for record in changeset.new_records:
474
530
  for field in select_related_fields:
475
- fk_id = getattr(record, f'{field}_id', None)
531
+ fk_id = getattr(record, f"{field}_id", None)
476
532
  if fk_id is not None:
477
533
  field_ids_map[field].add(fk_id)
478
534
 
@@ -501,13 +557,23 @@ class HookDispatcher:
501
557
  # Attach relationships to each record
502
558
  for record in changeset.new_records:
503
559
  for field in select_related_fields:
504
- fk_id = getattr(record, f'{field}_id', None)
560
+ fk_id = getattr(record, f"{field}_id", None)
505
561
  if fk_id is not None and field in field_objects_map:
506
562
  related_obj = field_objects_map[field].get(fk_id)
507
563
  if related_obj is not None:
564
+ # Preserve FK _id value before setattr (MTI/upsert fix)
565
+ id_field_name = f"{field}_id"
566
+ field_was_in_dict = id_field_name in record.__dict__
567
+ preserved_id = record.__dict__.get(id_field_name) if field_was_in_dict else None
568
+
508
569
  setattr(record, field, related_obj)
570
+
571
+ # Restore FK _id if it was in __dict__ (prevents Django descriptor from clearing it)
572
+ if field_was_in_dict and preserved_id is not None:
573
+ record.__dict__[id_field_name] = preserved_id
574
+
509
575
  # Also cache in Django's fields_cache for consistency
510
- if hasattr(record, '_state') and hasattr(record._state, 'fields_cache'):
576
+ if hasattr(record, "_state") and hasattr(record._state, "fields_cache"):
511
577
  record._state.fields_cache[field] = related_obj
512
578
 
513
579
  logger.info(f"🔗 BULK PRELOAD: Completed relationship attachment for {len(changeset.new_records)} records")
@@ -150,6 +150,13 @@ class BulkExecutor:
150
150
  if not objs:
151
151
  return 0
152
152
 
153
+ # Debug: Check FK values at bulk_update entry point
154
+ for obj in objs:
155
+ logger.debug("🚀 BULK_UPDATE_ENTRY: obj.pk=%s, business_id in __dict__=%s, value=%s",
156
+ getattr(obj, 'pk', 'None'),
157
+ 'business_id' in obj.__dict__,
158
+ obj.__dict__.get('business_id', 'NOT_IN_DICT'))
159
+
153
160
  # Ensure auto_now fields are included
154
161
  fields = self._add_auto_now_fields(fields, objs)
155
162
 
@@ -674,6 +681,14 @@ class BulkExecutor:
674
681
 
675
682
  logger.debug(f"Building CASE statements for {field_group.model_class.__name__} with {len(field_group.fields)} fields")
676
683
 
684
+ # Debug: Check if business_id is still in __dict__ before field extraction
685
+ for obj in objs:
686
+ if 'business_id' in obj.__dict__ or 'business' in field_group.fields:
687
+ logger.debug("🏗️ CASE_BUILD_START: obj.pk=%s, business_id in __dict__=%s, value=%s",
688
+ getattr(obj, 'pk', 'None'),
689
+ 'business_id' in obj.__dict__,
690
+ obj.__dict__.get('business_id', 'NOT_IN_DICT'))
691
+
677
692
  for field_name in field_group.fields:
678
693
  case_stmt = self._build_field_case_statement(field_name, field_group, root_pks, objs)
679
694
  if case_stmt:
@@ -699,6 +714,13 @@ class BulkExecutor:
699
714
 
700
715
  # Get and convert field value
701
716
  value = get_field_value_for_db(obj, field_name, field_group.model_class)
717
+
718
+ # Debug: Track business field extraction
719
+ if field_name == 'business':
720
+ logger.debug("🔧 CASE_FIELD_VALUE: obj.pk=%s, field='%s', raw_value=%s, business_id in __dict__=%s",
721
+ obj_pk, field_name, value,
722
+ 'business_id' in obj.__dict__ if hasattr(obj, '__dict__') else 'N/A')
723
+
702
724
  value = field.to_python(value)
703
725
 
704
726
  # Create WHEN with type casting
@@ -722,6 +744,7 @@ class BulkExecutor:
722
744
  ) -> int:
723
745
  """Execute the actual update query."""
724
746
  logger.debug(f"Executing update for {field_group.model_class.__name__} with {len(case_statements)} fields")
747
+ logger.debug(f"📝 UPDATE_FIELDS: {field_group.model_class.__name__} updating fields: {list(case_statements.keys())}")
725
748
 
726
749
  try:
727
750
  query_qs = base_qs.filter(**{f"{field_group.filter_field}__in": root_pks})
@@ -70,9 +70,15 @@ def _extract_fk_value(obj, field_name, field, model_cls):
70
70
  attname, field_was_explicitly_set, list(obj.__dict__.keys()))
71
71
 
72
72
  # Try direct access first
73
- value = getattr(obj, attname, None)
74
- logger.debug("🔍 FK_DIRECT_ACCESS: getattr(obj, '%s') = %s (type: %s)",
75
- attname, value, type(value).__name__ if value is not None else 'None')
73
+ # For MTI scenarios, try __dict__ first to avoid Django descriptor database lookups
74
+ if obj.__class__ != model_cls and attname in obj.__dict__:
75
+ value = obj.__dict__[attname]
76
+ logger.debug("🔍 FK_DIRECT_ACCESS_DICT: obj.__dict__['%s'] = %s (type: %s) [MTI bypass]",
77
+ attname, value, type(value).__name__ if value is not None else 'None')
78
+ else:
79
+ value = getattr(obj, attname, None)
80
+ logger.debug("🔍 FK_DIRECT_ACCESS: getattr(obj, '%s') = %s (type: %s)",
81
+ attname, value, type(value).__name__ if value is not None else 'None')
76
82
 
77
83
  # For MTI scenarios where parent field access fails on child instance
78
84
  # OR when the _id field is None but the relationship field is set (common in MTI)
@@ -165,6 +165,9 @@ class MTIHandler:
165
165
  batch_size = batch_size or len(objs)
166
166
  existing_record_ids = existing_record_ids or set()
167
167
  existing_pks_map = existing_pks_map or {}
168
+
169
+ logger.debug("🔧 MTI_CREATE_PLAN: model=%s, update_conflicts=%s, unique_fields=%s, update_fields=%s",
170
+ self.model_cls.__name__, update_conflicts, unique_fields, update_fields)
168
171
 
169
172
  # Set PKs on existing objects for proper updates
170
173
  self._set_existing_pks(objs, existing_pks_map)
@@ -410,7 +413,13 @@ class MTIHandler:
410
413
  update_fields: Optional[List[str]],
411
414
  ) -> Dict[str, any]:
412
415
  """Build upsert config for levels with matching unique constraints."""
416
+ logger.debug("🔧 UPSERT_CONFIG: model=%s, incoming_update_fields=%s, local_fields=%s",
417
+ model_class.__name__, update_fields, list(model_fields_by_name.keys()))
418
+
413
419
  filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
420
+
421
+ logger.debug("🔧 UPSERT_FILTERED: model=%s, filtered_update_fields=%s",
422
+ model_class.__name__, filtered_updates)
414
423
 
415
424
  # Add auto_now fields (critical for timestamp updates)
416
425
  auto_now_fields = self._get_auto_now_fields(model_class, model_fields_by_name)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.93"
3
+ version = "0.2.103"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"