data.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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. 'deepmerge',
  9. 'drange',
  10. 'flatten_dict',
  11. 'ranges_to_string',
  12. 'ranges_to_string_list',
  13. 'resolve_attr_path',
  14. 'shallow_compare_dict',
  15. 'string_to_ranges',
  16. )
  17. #
  18. # Dictionary utilities
  19. #
  20. def deepmerge(original, new):
  21. """
  22. Deep merge two dictionaries (new into original) and return a new dict
  23. """
  24. merged = dict(original)
  25. for key, val in new.items():
  26. if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
  27. merged[key] = deepmerge(original[key], val)
  28. else:
  29. merged[key] = val
  30. return merged
  31. def flatten_dict(d, prefix='', separator='.'):
  32. """
  33. Flatten nested dictionaries into a single level by joining key names with a separator.
  34. :param d: The dictionary to be flattened
  35. :param prefix: Initial prefix (if any)
  36. :param separator: The character to use when concatenating key names
  37. """
  38. ret = {}
  39. for k, v in d.items():
  40. key = separator.join([prefix, k]) if prefix else k
  41. if type(v) is dict:
  42. ret.update(flatten_dict(v, prefix=key, separator=separator))
  43. else:
  44. ret[key] = v
  45. return ret
  46. def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
  47. """
  48. Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
  49. the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
  50. """
  51. difference = {}
  52. for key, value in destination_dict.items():
  53. if key in exclude:
  54. continue
  55. if source_dict.get(key) != value:
  56. difference[key] = value
  57. return difference
  58. #
  59. # Array utilities
  60. #
  61. def array_to_ranges(array):
  62. """
  63. Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
  64. single-item tuples.
  65. Example:
  66. [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
  67. """
  68. group = (
  69. list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
  70. )
  71. return [
  72. (g[0], g[-1])[:len(g)] for g in group
  73. ]
  74. def array_to_string(array):
  75. """
  76. Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
  77. Example:
  78. [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
  79. """
  80. ret = []
  81. ranges = array_to_ranges(array)
  82. for value in ranges:
  83. if len(value) == 1:
  84. ret.append(str(value[0]))
  85. else:
  86. ret.append(f'{value[0]}-{value[1]}')
  87. return ', '.join(ret)
  88. #
  89. # Range utilities
  90. #
  91. def drange(start, end, step=decimal.Decimal(1)):
  92. """
  93. Decimal-compatible implementation of Python's range()
  94. """
  95. start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step)
  96. if start < end:
  97. while start < end:
  98. yield start
  99. start += step
  100. else:
  101. while start > end:
  102. yield start
  103. start += step
  104. def check_ranges_overlap(ranges):
  105. """
  106. Check for overlap in an iterable of NumericRanges.
  107. """
  108. ranges.sort(key=lambda x: x.lower)
  109. for i in range(1, len(ranges)):
  110. prev_range = ranges[i - 1]
  111. prev_upper = prev_range.upper if prev_range.upper_inc else prev_range.upper - 1
  112. lower = ranges[i].lower if ranges[i].lower_inc else ranges[i].lower + 1
  113. if prev_upper >= lower:
  114. return True
  115. return False
  116. def ranges_to_string_list(ranges):
  117. """
  118. Convert numeric ranges to a list of display strings.
  119. Each range is rendered as "lower-upper" or "lower" (for singletons).
  120. Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
  121. This underpins ``ranges_to_string()``, which joins the result with commas.
  122. Example:
  123. [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
  124. """
  125. if not ranges:
  126. return []
  127. output: list[str] = []
  128. for r in ranges:
  129. # Compute inclusive bounds regardless of how the DB range is stored.
  130. lower = r.lower if r.lower_inc else r.lower + 1
  131. upper = r.upper if r.upper_inc else r.upper - 1
  132. output.append(f"{lower}-{upper}" if lower != upper else str(lower))
  133. return output
  134. def ranges_to_string(ranges):
  135. """
  136. Converts a list of ranges into a string representation.
  137. This function takes a list of range objects and produces a string
  138. representation of those ranges. Each range is represented as a
  139. hyphen-separated pair of lower and upper bounds, with inclusive or
  140. exclusive bounds adjusted accordingly. If the lower and upper bounds
  141. of a range are the same, only the single value is added to the string.
  142. Intended for use with ArrayField.
  143. Example:
  144. [NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
  145. """
  146. if not ranges:
  147. return ''
  148. return ','.join(ranges_to_string_list(ranges))
  149. def string_to_ranges(value):
  150. """
  151. Converts a string representation of numeric ranges into a list of NumericRange objects.
  152. This function parses a string containing numeric values and ranges separated by commas (e.g.,
  153. "1-5,8,10-12") and converts it into a list of NumericRange objects.
  154. In the case of a single integer, it is treated as a range where the start and end
  155. are equal. The returned ranges are represented as half-open intervals [lower, upper).
  156. Intended for use with ArrayField.
  157. Example:
  158. "1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
  159. """
  160. if not value:
  161. return None
  162. value.replace(' ', '') # Remove whitespace
  163. values = []
  164. for data in value.split(','):
  165. dash_range = data.strip().split('-')
  166. if len(dash_range) == 1 and str(dash_range[0]).isdigit():
  167. # Single integer value; expand to a range
  168. lower = dash_range[0]
  169. upper = dash_range[0]
  170. elif len(dash_range) == 2 and str(dash_range[0]).isdigit() and str(dash_range[1]).isdigit():
  171. # The range has two values and both are valid integers
  172. lower = dash_range[0]
  173. upper = dash_range[1]
  174. else:
  175. return None
  176. values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
  177. return values
  178. #
  179. # Attribute resolution
  180. #
  181. def resolve_attr_path(obj, path):
  182. """
  183. Follow a dotted path across attributes and/or dictionary keys and return the final value.
  184. Parameters:
  185. obj: The starting object
  186. path: The dotted path to follow (e.g. "foo.bar.baz")
  187. """
  188. cur = obj
  189. for part in path.split('.'):
  190. if cur is None:
  191. return None
  192. cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part)
  193. return cur