data.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import decimal
  2. from itertools import count, groupby
  3. from django.db.backends.postgresql.psycopg_any import NumericRange
  4. __all__ = (
  5. 'array_to_ranges',
  6. 'array_to_string',
  7. 'check_ranges_overlap',
  8. 'deep_compare_dict',
  9. 'deepmerge',
  10. 'drange',
  11. 'flatten_dict',
  12. 'get_config_value_ci',
  13. 'get_inclusive_integer_range_bounds',
  14. 'normalize_integer_range',
  15. 'normalize_update_fields',
  16. 'ranges_to_string',
  17. 'ranges_to_string_list',
  18. 'resolve_attr_path',
  19. 'shallow_compare_dict',
  20. 'string_to_ranges',
  21. )
  22. #
  23. # Dictionary utilities
  24. #
  25. def get_config_value_ci(config_dict, key, default=None):
  26. """
  27. Retrieve a value from a dictionary using case-insensitive key matching.
  28. """
  29. if key in config_dict:
  30. return config_dict[key]
  31. key_lower = key.lower()
  32. for config_key, value in config_dict.items():
  33. if config_key.lower() == key_lower:
  34. return value
  35. return default
  36. def deepmerge(original, new):
  37. """
  38. Deep merge two dictionaries (new into original) and return a new dict
  39. """
  40. merged = dict(original)
  41. for key, val in new.items():
  42. if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
  43. merged[key] = deepmerge(original[key], val)
  44. else:
  45. merged[key] = val
  46. return merged
  47. def flatten_dict(d, prefix='', separator='.'):
  48. """
  49. Flatten nested dictionaries into a single level by joining key names with a separator.
  50. :param d: The dictionary to be flattened
  51. :param prefix: Initial prefix (if any)
  52. :param separator: The character to use when concatenating key names
  53. """
  54. ret = {}
  55. for k, v in d.items():
  56. key = separator.join([prefix, k]) if prefix else k
  57. if type(v) is dict:
  58. ret.update(flatten_dict(v, prefix=key, separator=separator))
  59. else:
  60. ret[key] = v
  61. return ret
  62. def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
  63. """
  64. Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
  65. the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
  66. """
  67. difference = {}
  68. for key, value in destination_dict.items():
  69. if key in exclude:
  70. continue
  71. if source_dict.get(key) != value:
  72. difference[key] = value
  73. return difference
  74. def deep_compare_dict(source_dict, destination_dict, exclude=tuple()):
  75. """
  76. Return a two-tuple of dictionaries (added, removed) representing the differences between source_dict and
  77. destination_dict. For values which are themselves dicts, the comparison is performed recursively such that only
  78. the changed keys within the nested dict are included. `exclude` is a list or tuple of keys to be ignored.
  79. """
  80. added = {}
  81. removed = {}
  82. all_keys = set(source_dict) | set(destination_dict)
  83. for key in all_keys:
  84. if key in exclude:
  85. continue
  86. src_val = source_dict.get(key)
  87. dst_val = destination_dict.get(key)
  88. if src_val == dst_val:
  89. continue
  90. if isinstance(src_val, dict) and isinstance(dst_val, dict):
  91. sub_added, sub_removed = deep_compare_dict(src_val, dst_val)
  92. if sub_added or sub_removed:
  93. added[key] = sub_added
  94. removed[key] = sub_removed
  95. else:
  96. added[key] = dst_val
  97. removed[key] = src_val
  98. return added, removed
  99. def normalize_update_fields(kwargs):
  100. """
  101. Replace `kwargs['update_fields']` with a frozenset and return it, so a save() override can
  102. run membership tests without consuming a one-shot iterable. `None` and an absent key are
  103. left alone.
  104. """
  105. update_fields = kwargs.get('update_fields')
  106. if update_fields is not None:
  107. update_fields = frozenset(update_fields)
  108. kwargs['update_fields'] = update_fields
  109. return update_fields
  110. #
  111. # Array utilities
  112. #
  113. def array_to_ranges(array):
  114. """
  115. Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
  116. single-item tuples.
  117. Example:
  118. [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
  119. """
  120. group = (
  121. list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
  122. )
  123. return [
  124. (g[0], g[-1])[:len(g)] for g in group
  125. ]
  126. def array_to_string(array):
  127. """
  128. Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
  129. Example:
  130. [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
  131. """
  132. ret = []
  133. ranges = array_to_ranges(array)
  134. for value in ranges:
  135. if len(value) == 1:
  136. ret.append(str(value[0]))
  137. else:
  138. ret.append(f'{value[0]}-{value[1]}')
  139. return ', '.join(ret)
  140. #
  141. # Range utilities
  142. #
  143. def drange(start, end, step=decimal.Decimal(1)):
  144. """
  145. Decimal-compatible implementation of Python's range()
  146. """
  147. start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step)
  148. if start < end:
  149. while start < end:
  150. yield start
  151. start += step
  152. else:
  153. while start > end:
  154. yield start
  155. start += step
  156. def get_inclusive_integer_range_bounds(value_range):
  157. """
  158. Return the lower and upper bounds of a bounded, non-empty discrete
  159. integer range as inclusive values.
  160. For example, ``[10, 20)`` is returned as ``(10, 19)``, while
  161. ``[10, 20]`` is returned as ``(10, 20)``.
  162. Both bounds must be non-``None``; unbounded ranges are not supported.
  163. """
  164. lower = value_range.lower if value_range.lower_inc else value_range.lower + 1
  165. upper = value_range.upper if value_range.upper_inc else value_range.upper - 1
  166. return lower, upper
  167. def normalize_integer_range(value_range):
  168. """
  169. Return an equivalent canonical half-open ``[)`` range for a bounded,
  170. non-empty discrete integer range, regardless of the input range's
  171. bounds metadata.
  172. """
  173. lower, upper = get_inclusive_integer_range_bounds(value_range)
  174. return NumericRange(lower, upper + 1, bounds='[)')
  175. def check_ranges_overlap(ranges):
  176. """
  177. Check for overlap in an iterable of NumericRanges. Does not mutate the input.
  178. """
  179. ranges = sorted(ranges, key=lambda value_range: get_inclusive_integer_range_bounds(value_range)[0])
  180. for i in range(1, len(ranges)):
  181. prev_upper = get_inclusive_integer_range_bounds(ranges[i - 1])[1]
  182. lower = get_inclusive_integer_range_bounds(ranges[i])[0]
  183. if prev_upper >= lower:
  184. return True
  185. return False
  186. def ranges_to_string_list(ranges):
  187. """
  188. Convert numeric ranges to a list of display strings.
  189. Each range is rendered as "lower-upper" or "lower" (for singletons).
  190. Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
  191. This underpins ``ranges_to_string()``, which joins the result with commas.
  192. Example:
  193. [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
  194. """
  195. if not ranges:
  196. return []
  197. output: list[str] = []
  198. for r in ranges:
  199. lower, upper = get_inclusive_integer_range_bounds(r)
  200. output.append(f"{lower}-{upper}" if lower != upper else str(lower))
  201. return output
  202. def ranges_to_string(ranges):
  203. """
  204. Converts a list of ranges into a string representation.
  205. This function takes a list of range objects and produces a string
  206. representation of those ranges. Each range is represented as a
  207. hyphen-separated pair of lower and upper bounds, with inclusive or
  208. exclusive bounds adjusted accordingly. If the lower and upper bounds
  209. of a range are the same, only the single value is added to the string.
  210. Intended for use with ArrayField.
  211. Example:
  212. [NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
  213. """
  214. if not ranges:
  215. return ''
  216. return ','.join(ranges_to_string_list(ranges))
  217. def string_to_ranges(value):
  218. """
  219. Converts a string representation of numeric ranges into a list of NumericRange objects.
  220. This function parses a string containing numeric values and ranges separated by commas (e.g.,
  221. "1-5,8,10-12") and converts it into a list of NumericRange objects.
  222. In the case of a single integer, it is treated as a range where the start and end
  223. are equal. The returned ranges are represented as half-open intervals [lower, upper).
  224. Intended for use with ArrayField.
  225. Example:
  226. "1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
  227. """
  228. if not value:
  229. return None
  230. value.replace(' ', '') # Remove whitespace
  231. values = []
  232. for data in value.split(','):
  233. dash_range = data.strip().split('-')
  234. if len(dash_range) == 1 and str(dash_range[0]).isdigit():
  235. # Single integer value; expand to a range
  236. lower = dash_range[0]
  237. upper = dash_range[0]
  238. elif len(dash_range) == 2 and str(dash_range[0]).isdigit() and str(dash_range[1]).isdigit():
  239. # The range has two values and both are valid integers
  240. lower = dash_range[0]
  241. upper = dash_range[1]
  242. else:
  243. return None
  244. values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
  245. return values
  246. #
  247. # Attribute resolution
  248. #
  249. def resolve_attr_path(obj, path):
  250. """
  251. Follow a dotted path across attributes and/or dictionary keys and return the final value.
  252. Parameters:
  253. obj: The starting object
  254. path: The dotted path to follow (e.g. "foo.bar.baz")
  255. """
  256. cur = obj
  257. for part in path.split('.'):
  258. if cur is None:
  259. return None
  260. try:
  261. cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part)
  262. except AttributeError:
  263. cur = None
  264. return cur