cooptools 1.76__tar.gz → 1.78__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 (200) hide show
  1. {cooptools-1.76 → cooptools-1.78}/PKG-INFO +1 -1
  2. cooptools-1.78/cooptools/currency.py +154 -0
  3. cooptools-1.78/cooptools/expertise/__init__.py +6 -0
  4. cooptools-1.78/cooptools/expertise/expertise_state.py +58 -0
  5. cooptools-1.78/cooptools/persistence/__init__.py +36 -0
  6. cooptools-1.78/cooptools/persistence/enum_state.py +81 -0
  7. cooptools-1.78/cooptools/persistence/stateful.py +148 -0
  8. {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/PKG-INFO +1 -1
  9. {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/SOURCES.txt +6 -0
  10. {cooptools-1.76 → cooptools-1.78}/setup.py +1 -1
  11. cooptools-1.78/tests/test_currency.py +116 -0
  12. cooptools-1.78/tests/test_expertise_state.py +120 -0
  13. cooptools-1.78/tests/test_persistence.py +176 -0
  14. cooptools-1.76/cooptools/currency.py +0 -134
  15. cooptools-1.76/cooptools/expertise/__init__.py +0 -2
  16. cooptools-1.76/tests/test_currency.py +0 -31
  17. {cooptools-1.76 → cooptools-1.78}/MANIFEST.in +0 -0
  18. {cooptools-1.76 → cooptools-1.78}/README.md +0 -0
  19. {cooptools-1.76 → cooptools-1.78}/cooptools/__init__.py +0 -0
  20. {cooptools-1.76 → cooptools-1.78}/cooptools/anchor.py +0 -0
  21. {cooptools-1.76 → cooptools-1.78}/cooptools/asyncable.py +0 -0
  22. {cooptools-1.76 → cooptools-1.78}/cooptools/cli/CliAtomicUserInteraction.py +0 -0
  23. {cooptools-1.76 → cooptools-1.78}/cooptools/cli/CliMenu.py +0 -0
  24. {cooptools-1.76 → cooptools-1.78}/cooptools/cli/__init__.py +0 -0
  25. {cooptools-1.76 → cooptools-1.78}/cooptools/cli/fileContentReturn.py +0 -0
  26. {cooptools-1.76 → cooptools-1.78}/cooptools/cnxn_info.py +0 -0
  27. {cooptools-1.76 → cooptools-1.78}/cooptools/colors.py +0 -0
  28. {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/__init__.py +0 -0
  29. {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/commandController.py +0 -0
  30. {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/commandProtocol.py +0 -0
  31. {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/exceptions.py +0 -0
  32. {cooptools-1.76 → cooptools-1.78}/cooptools/common.py +0 -0
  33. {cooptools-1.76 → cooptools-1.78}/cooptools/config.py +0 -0
  34. {cooptools-1.76 → cooptools-1.78}/cooptools/coopDataclass.py +0 -0
  35. {cooptools-1.76 → cooptools-1.78}/cooptools/coopEnum.py +0 -0
  36. {cooptools-1.76 → cooptools-1.78}/cooptools/coopthreading.py +0 -0
  37. {cooptools-1.76 → cooptools-1.78}/cooptools/dataRefresher/__init__.py +0 -0
  38. {cooptools-1.76 → cooptools-1.78}/cooptools/dataRefresher/dataHub.py +0 -0
  39. {cooptools-1.76 → cooptools-1.78}/cooptools/dataRefresher/dataRefresher.py +0 -0
  40. {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/__init__.py +0 -0
  41. {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/dataProcessor.py +0 -0
  42. {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/dataStoreProtocol.py +0 -0
  43. {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/dbConnectionURI.py +0 -0
  44. {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/inMemoryDataStore.py +0 -0
  45. {cooptools-1.76 → cooptools-1.78}/cooptools/date_utils.py +0 -0
  46. {cooptools-1.76 → cooptools-1.78}/cooptools/decay.py +0 -0
  47. {cooptools-1.76 → cooptools-1.78}/cooptools/decor.py +0 -0
  48. {cooptools-1.76 → cooptools-1.78}/cooptools/dictPolicies.py +0 -0
  49. {cooptools-1.76 → cooptools-1.78}/cooptools/exceptions.py +0 -0
  50. {cooptools-1.76 → cooptools-1.78}/cooptools/expertise/expertiseArgs.py +0 -0
  51. {cooptools-1.76 → cooptools-1.78}/cooptools/expertise/expertiseSchedules.py +0 -0
  52. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/__init__.py +0 -0
  53. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/dicounted_cashflow_analysis.py +0 -0
  54. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/fund_projection.py +0 -0
  55. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/fund_projection_renderer.py +0 -0
  56. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/futureValueProjector.py +0 -0
  57. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/growth_projections.py +0 -0
  58. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/reports.py +0 -0
  59. {cooptools-1.76 → cooptools-1.78}/cooptools/finance/utils.py +0 -0
  60. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/__init__.py +0 -0
  61. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/circle_utils.py +0 -0
  62. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/common.py +0 -0
  63. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/curve_utils.py +0 -0
  64. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/curves.py +0 -0
  65. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/line_utils.py +0 -0
  66. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/polygon_utils.py +0 -0
  67. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/rect_utils.py +0 -0
  68. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/triangle_utils.py +0 -0
  69. {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/vector_utils.py +0 -0
  70. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/__init__.py +0 -0
  71. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/astar_results_viewer.py +0 -0
  72. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/draw.py +0 -0
  73. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/flow_field.py +0 -0
  74. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/graph.py +0 -0
  75. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/graph_dcs.py +0 -0
  76. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/graph_definitions.py +0 -0
  77. {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/utils.py +0 -0
  78. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/__init__.py +0 -0
  79. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/actionItemTracker/__init__.py +0 -0
  80. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/actionItemTracker/ai_tracker.py +0 -0
  81. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/actionItemTracker/dcs.py +0 -0
  82. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/discreteEventSimulator/__init__.py +0 -0
  83. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/discreteEventSimulator/discreteEventSimulator.py +0 -0
  84. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/jsonIO.py +0 -0
  85. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/metrics.py +0 -0
  86. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/triggerActionSystem/__init__.py +0 -0
  87. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/triggerActionSystem/signal.py +0 -0
  88. {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/triggerActionSystem/trigger.py +0 -0
  89. {cooptools-1.76 → cooptools-1.78}/cooptools/loggingHelpers.py +0 -0
  90. {cooptools-1.76 → cooptools-1.78}/cooptools/marchingSquares.py +0 -0
  91. {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/__init__.py +0 -0
  92. {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/cli.py +0 -0
  93. {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/dcs.py +0 -0
  94. {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/selectionCriteria.py +0 -0
  95. {cooptools-1.76 → cooptools-1.78}/cooptools/matrixManipulation.py +0 -0
  96. {cooptools-1.76 → cooptools-1.78}/cooptools/os_manip.py +0 -0
  97. {cooptools-1.76 → cooptools-1.78}/cooptools/pandasHelpers.py +0 -0
  98. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/__init__.py +0 -0
  99. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/catmullRomFollower.py +0 -0
  100. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/demo/__init__.py +0 -0
  101. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/demo/server.py +0 -0
  102. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/kinematicGoalSeeker.py +0 -0
  103. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/motion_profile_utils.py +0 -0
  104. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/orbit_fuzzer.py +0 -0
  105. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/path_utils.py +0 -0
  106. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/straightLineFollower.py +0 -0
  107. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/trajectory_provider.py +0 -0
  108. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/waypoint.py +0 -0
  109. {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/waypointFollower.py +0 -0
  110. {cooptools-1.76 → cooptools-1.78}/cooptools/physics/__init__.py +0 -0
  111. {cooptools-1.76 → cooptools-1.78}/cooptools/physics/kinematic.py +0 -0
  112. {cooptools-1.76 → cooptools-1.78}/cooptools/physics/kinematic_utils.py +0 -0
  113. {cooptools-1.76 → cooptools-1.78}/cooptools/physics/movement.py +0 -0
  114. {cooptools-1.76 → cooptools-1.78}/cooptools/physics/trajectory.py +0 -0
  115. {cooptools-1.76 → cooptools-1.78}/cooptools/plotting.py +0 -0
  116. {cooptools-1.76 → cooptools-1.78}/cooptools/printing.py +0 -0
  117. {cooptools-1.76 → cooptools-1.78}/cooptools/protocols.py +0 -0
  118. {cooptools-1.76 → cooptools-1.78}/cooptools/qualifiers/__init__.py +0 -0
  119. {cooptools-1.76 → cooptools-1.78}/cooptools/qualifiers/cli.py +0 -0
  120. {cooptools-1.76 → cooptools-1.78}/cooptools/qualifiers/qualifier.py +0 -0
  121. {cooptools-1.76 → cooptools-1.78}/cooptools/randoms.py +0 -0
  122. {cooptools-1.76 → cooptools-1.78}/cooptools/register.py +0 -0
  123. {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/__init__.py +0 -0
  124. {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/dcs.py +0 -0
  125. {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/enums.py +0 -0
  126. {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/reservationmanager.py +0 -0
  127. {cooptools-1.76 → cooptools-1.78}/cooptools/retry.py +0 -0
  128. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/__init__.py +0 -0
  129. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/__init__.py +0 -0
  130. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/gridState.py +0 -0
  131. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/grid_base.py +0 -0
  132. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/hexGrid.py +0 -0
  133. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/rectGrid.py +0 -0
  134. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/hex_utils.py +0 -0
  135. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/sect_utils.py +0 -0
  136. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/sectorTree/__init__.py +0 -0
  137. {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/sectorTree/sectorTree.py +0 -0
  138. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/__init__.py +0 -0
  139. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/activityReport.py +0 -0
  140. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/controlChart/__init__.py +0 -0
  141. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/controlChart/controlChart.py +0 -0
  142. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/controlChart/plotting.py +0 -0
  143. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/oee/__init__.py +0 -0
  144. {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/oee/oeeHub.py +0 -0
  145. {cooptools-1.76 → cooptools-1.78}/cooptools/taskProcessing/__init__.py +0 -0
  146. {cooptools-1.76 → cooptools-1.78}/cooptools/taskProcessing/dcs.py +0 -0
  147. {cooptools-1.76 → cooptools-1.78}/cooptools/taskProcessing/taskProcessor.py +0 -0
  148. {cooptools-1.76 → cooptools-1.78}/cooptools/timeTracker/__init__.py +0 -0
  149. {cooptools-1.76 → cooptools-1.78}/cooptools/timeTracker/decay.py +0 -0
  150. {cooptools-1.76 → cooptools-1.78}/cooptools/timeTracker/timeTracker.py +0 -0
  151. {cooptools-1.76 → cooptools-1.78}/cooptools/timeWindow.py +0 -0
  152. {cooptools-1.76 → cooptools-1.78}/cooptools/toggles.py +0 -0
  153. {cooptools-1.76 → cooptools-1.78}/cooptools/transform.py +0 -0
  154. {cooptools-1.76 → cooptools-1.78}/cooptools/trends.py +0 -0
  155. {cooptools-1.76 → cooptools-1.78}/cooptools/typeProviders.py +0 -0
  156. {cooptools-1.76 → cooptools-1.78}/cooptools/typevalidation.py +0 -0
  157. {cooptools-1.76 → cooptools-1.78}/cooptools/version.py +0 -0
  158. {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/dependency_links.txt +0 -0
  159. {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/not-zip-safe +0 -0
  160. {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/requires.txt +0 -0
  161. {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/top_level.txt +0 -0
  162. {cooptools-1.76 → cooptools-1.78}/requirements.txt +0 -0
  163. {cooptools-1.76 → cooptools-1.78}/setup.cfg +0 -0
  164. {cooptools-1.76 → cooptools-1.78}/tests/test_CliAtomicUserInteraction.py +0 -0
  165. {cooptools-1.76 → cooptools-1.78}/tests/test_asyncable.py +0 -0
  166. {cooptools-1.76 → cooptools-1.78}/tests/test_catmullRomFollower.py +0 -0
  167. {cooptools-1.76 → cooptools-1.78}/tests/test_cdp_bugs.py +0 -0
  168. {cooptools-1.76 → cooptools-1.78}/tests/test_commandDesignPattern.py +0 -0
  169. {cooptools-1.76 → cooptools-1.78}/tests/test_common.py +0 -0
  170. {cooptools-1.76 → cooptools-1.78}/tests/test_config.py +0 -0
  171. {cooptools-1.76 → cooptools-1.78}/tests/test_coopEnum.py +0 -0
  172. {cooptools-1.76 → cooptools-1.78}/tests/test_curves.py +0 -0
  173. {cooptools-1.76 → cooptools-1.78}/tests/test_date_utils.py +0 -0
  174. {cooptools-1.76 → cooptools-1.78}/tests/test_flow_field.py +0 -0
  175. {cooptools-1.76 → cooptools-1.78}/tests/test_flow_field_perf.py +0 -0
  176. {cooptools-1.76 → cooptools-1.78}/tests/test_fund_projection.py +0 -0
  177. {cooptools-1.76 → cooptools-1.78}/tests/test_graph_node_classification.py +0 -0
  178. {cooptools-1.76 → cooptools-1.78}/tests/test_kinematicGoalSeeker.py +0 -0
  179. {cooptools-1.76 → cooptools-1.78}/tests/test_kinematic_utils.py +0 -0
  180. {cooptools-1.76 → cooptools-1.78}/tests/test_os_manip.py +0 -0
  181. {cooptools-1.76 → cooptools-1.78}/tests/test_pandas.py +0 -0
  182. {cooptools-1.76 → cooptools-1.78}/tests/test_path_utils.py +0 -0
  183. {cooptools-1.76 → cooptools-1.78}/tests/test_printing.py +0 -0
  184. {cooptools-1.76 → cooptools-1.78}/tests/test_qualifiers.py +0 -0
  185. {cooptools-1.76 → cooptools-1.78}/tests/test_sectors.py +0 -0
  186. {cooptools-1.76 → cooptools-1.78}/tests/test_sectors_hex.py +0 -0
  187. {cooptools-1.76 → cooptools-1.78}/tests/test_sectors_sectorTree.py +0 -0
  188. {cooptools-1.76 → cooptools-1.78}/tests/test_statistics.py +0 -0
  189. {cooptools-1.76 → cooptools-1.78}/tests/test_straightLineFollower.py +0 -0
  190. {cooptools-1.76 → cooptools-1.78}/tests/test_target_seek.py +0 -0
  191. {cooptools-1.76 → cooptools-1.78}/tests/test_tasks.py +0 -0
  192. {cooptools-1.76 → cooptools-1.78}/tests/test_toggles.py +0 -0
  193. {cooptools-1.76 → cooptools-1.78}/tests/test_trajectory.py +0 -0
  194. {cooptools-1.76 → cooptools-1.78}/tests/test_trends.py +0 -0
  195. {cooptools-1.76 → cooptools-1.78}/tests/test_typevalidation.py +0 -0
  196. {cooptools-1.76 → cooptools-1.78}/tests/test_vector_utils.py +0 -0
  197. {cooptools-1.76 → cooptools-1.78}/tests/test_version.py +0 -0
  198. {cooptools-1.76 → cooptools-1.78}/tests/test_waypointFollower.py +0 -0
  199. {cooptools-1.76 → cooptools-1.78}/tests/tests_graph.py +0 -0
  200. {cooptools-1.76 → cooptools-1.78}/tests/tests_gridsystem.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cooptools
3
- Version: 1.76
3
+ Version: 1.78
4
4
  Summary: Generic Tooling
5
5
  Home-page: https://github.com/tylertjburns/cooptools
6
6
  Author: tburns
@@ -0,0 +1,154 @@
1
+ import dataclasses
2
+ from dataclasses import dataclass, asdict
3
+ from decimal import Decimal, ROUND_HALF_UP
4
+
5
+ CENTS_PER_DOLLAR = 100
6
+ _CENT = Decimal('0.01')
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class USD:
10
+ dollars: int
11
+ cents: int
12
+
13
+ def __post_init__(self):
14
+ """ Canonicalizes to a single representation per amount so that equality and hashing agree
15
+ with value. Both fields carry the sign of the total, and cents always lands in (-100, 100)."""
16
+ total_cents = self.dollars * CENTS_PER_DOLLAR + self.cents
17
+ sign = -1 if total_cents < 0 else 1
18
+ dollars, cents = divmod(abs(total_cents), CENTS_PER_DOLLAR)
19
+ object.__setattr__(self, "dollars", sign * dollars)
20
+ object.__setattr__(self, "cents", sign * cents)
21
+
22
+ @classmethod
23
+ def zero(cls):
24
+ return USD(dollars=0, cents=0)
25
+
26
+ @classmethod
27
+ def from_val(cls, val: float | str | int | Decimal):
28
+ if isinstance(val, USD):
29
+ return val
30
+
31
+ # bool is a subclass of int, but a truth value is never an amount of money
32
+ if isinstance(val, bool):
33
+ raise TypeError(f'{val} [{type(val)}] not supported to be converted to CurrencySchema')
34
+
35
+ if isinstance(val, float):
36
+ # str() of a float is the shortest repr that round-trips, which recovers the decimal
37
+ # the caller actually meant rather than the binary approximation stored for it
38
+ dec = Decimal(str(val))
39
+ elif isinstance(val, (str, int, Decimal)):
40
+ dec = Decimal(val)
41
+ else:
42
+ raise TypeError(f'{val} [{type(val)}] not supported to be converted to CurrencySchema')
43
+
44
+ total_cents = int((dec * CENTS_PER_DOLLAR).to_integral_value(rounding=ROUND_HALF_UP))
45
+ return USD(dollars=0, cents=total_cents)
46
+
47
+ @property
48
+ def AmountAsDecimal(self) -> Decimal:
49
+ """ The exact value. This is what should cross a persistence or numeric boundary
50
+ (SQLAlchemy Numeric, pandas, arithmetic outside this class)."""
51
+ return (Decimal(self.dollars * CENTS_PER_DOLLAR + self.cents) / CENTS_PER_DOLLAR).quantize(_CENT)
52
+
53
+ @property
54
+ def AmountFloated(self) -> float:
55
+ return float(self.AmountAsDecimal)
56
+
57
+ @classmethod
58
+ def verify_val(cls, val):
59
+ if isinstance(val, (float, int, str, Decimal)) and not isinstance(val, bool):
60
+ val = USD.from_val(val)
61
+
62
+ if not isinstance(val, USD):
63
+ raise NotImplementedError(f"{val} is not of type [{USD}]")
64
+
65
+ return val
66
+
67
+ def __add__(self, other):
68
+ other = self.verify_val(other)
69
+ return USD(dollars=self.dollars + other.dollars,
70
+ cents=self.cents + other.cents)
71
+
72
+ def __radd__(self, other):
73
+ return self.__add__(other)
74
+
75
+ def __sub__(self, other):
76
+ other = self.verify_val(other)
77
+ return USD(dollars=self.dollars - other.dollars,
78
+ cents=self.cents - other.cents)
79
+
80
+ def __rsub__(self, other):
81
+ return self.verify_val(other).__sub__(self)
82
+
83
+ def __mul__(self, other):
84
+ if isinstance(other, bool) or not isinstance(other, (int, float, Decimal)):
85
+ raise NotImplementedError(f"type {type(other)} cannot be multipled with [{USD}]")
86
+
87
+ factor = Decimal(str(other)) if isinstance(other, float) else Decimal(other)
88
+ return USD.from_val(self.AmountAsDecimal * factor)
89
+
90
+ def __rmul__(self, other):
91
+ return self.__mul__(other)
92
+
93
+ def __truediv__(self, other):
94
+ # dividing two amounts yields a dimensionless ratio rather than money, and callers feed it
95
+ # into ordinary float math
96
+ if isinstance(other, USD):
97
+ return float(self.AmountAsDecimal / other.AmountAsDecimal)
98
+
99
+ if isinstance(other, bool) or not isinstance(other, (int, float, Decimal)):
100
+ raise NotImplementedError(f"cannot divide [{USD}] by type {type(other)} ")
101
+
102
+ divisor = Decimal(str(other)) if isinstance(other, float) else Decimal(other)
103
+ return USD.from_val(self.AmountAsDecimal / divisor)
104
+
105
+ def __gt__(self, other):
106
+ return self.AmountAsDecimal > self.verify_val(other).AmountAsDecimal
107
+
108
+ def __lt__(self, other):
109
+ return self.AmountAsDecimal < self.verify_val(other).AmountAsDecimal
110
+
111
+ def __ge__(self, other):
112
+ return not self.__lt__(other)
113
+
114
+ def __le__(self, other):
115
+ return not self.__gt__(other)
116
+
117
+ def __str__(self):
118
+ return "${:,.2f}".format(self.AmountAsDecimal)
119
+
120
+ def __format__(self, format_spec):
121
+ """ An empty spec gives the currency-decorated str(); any other spec formats the underlying
122
+ decimal, so "{:,.2f}".format(usd) behaves the way callers expect of a numeric type."""
123
+ if format_spec == "":
124
+ return str(self)
125
+ return format(self.AmountAsDecimal, format_spec)
126
+
127
+ def __neg__(self):
128
+ return USD(dollars=-self.dollars, cents=-self.cents)
129
+
130
+ def __abs__(self):
131
+ return USD(dollars=abs(self.dollars), cents=abs(self.cents))
132
+
133
+ def __bool__(self):
134
+ return self.dollars != 0 or self.cents != 0
135
+
136
+ def __float__(self):
137
+ return self.AmountFloated
138
+
139
+ def to_dict(self):
140
+ return asdict(self)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ a = USD.from_val(1.5)
145
+ b = USD.from_val(3.5)
146
+ d = USD(1, 250)
147
+ c = a + b
148
+ print(c)
149
+ print(d)
150
+ print(d.dollars, d.cents)
151
+ e = d + c
152
+ print(e, e.dollars, e.cents)
153
+ print(e.to_dict())
154
+ print(dataclasses.asdict(e))
@@ -0,0 +1,6 @@
1
+ from cooptools.expertise.expertiseArgs import *
2
+ from cooptools.expertise.expertiseSchedules import *
3
+ from cooptools.expertise.expertise_state import (
4
+ expertise_from_state,
5
+ expertise_to_state,
6
+ )
@@ -0,0 +1,58 @@
1
+ """Expertise across the JSON boundary.
2
+
3
+ Beside the calculator it serializes rather than in a persistence package, because
4
+ what these two functions know about is *expertise* -- which accumulator holds the
5
+ truth and which views are derived -- and only incidentally that the destination is
6
+ JSON. Grouped by mechanism they would sit next to enum conversion, which they have
7
+ nothing in common with; grouped by subject they sit next to the thing that breaks
8
+ them when it changes.
9
+
10
+ Functions over `ExpertiseCalculator` rather than methods on it, so that reading
11
+ and writing its state is available without widening the calculator's own API.
12
+
13
+ Deliberately asymmetric, and worth knowing why:
14
+
15
+ - Reading has to reach `_expertise_args`, because the calculator exposes only
16
+ derived views (`PercExpert`, `CurrentTimeReductionPerc`) and neither can be
17
+ inverted back into the accumulator that produced it.
18
+ - Writing does not, because `schedule` is public and the increments are public,
19
+ so a fresh calculator can be built and wound forward. That is also what makes
20
+ applying twice give the same answer as applying once -- incrementing an
21
+ existing calculator would accumulate instead of replace.
22
+
23
+ The read is the half that would break if the calculator's internals moved, which
24
+ is the argument for these eventually becoming methods on it.
25
+ """
26
+ import logging
27
+ from typing import Any, Dict
28
+
29
+ from cooptools.expertise.expertiseSchedules import ExpertiseCalculator
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def expertise_to_state(calculator: ExpertiseCalculator) -> Dict[str, Any]:
35
+ """The accumulators behind an expertise calculator.
36
+
37
+ The schedule is not written -- it is definition, rebuilt from tuning by
38
+ whatever constructs the calculator's owner.
39
+ """
40
+ args = calculator._expertise_args
41
+ return {'n_runs': args.n_runs,
42
+ 'accumulated_s': args.accumulated_s,
43
+ 'exp': args.exp}
44
+
45
+
46
+ def expertise_from_state(state: Dict[str, Any],
47
+ calculator: ExpertiseCalculator) -> ExpertiseCalculator:
48
+ """A calculator on `calculator`'s schedule, wound to the saved accumulators.
49
+
50
+ Returns a new instance rather than mutating: the caller reassigns, which is
51
+ what makes this replace rather than accumulate.
52
+ """
53
+ restored = ExpertiseCalculator(schedule=calculator.schedule)
54
+ saved = state or {}
55
+ restored.increment_n_runs(saved.get('n_runs', 0))
56
+ restored.increment_s_producting(saved.get('accumulated_s', 0))
57
+ restored.increment_exp(saved.get('exp', 0))
58
+ return restored
@@ -0,0 +1,36 @@
1
+ """Handing an object's state out and taking it back, without a base class.
2
+
3
+ `Stateful` is a structural `typing.Protocol`, so a class conforms by having
4
+ `to_state`/`apply_state` and importing nothing to do it. That is what lets a type
5
+ in one package be persisted by another with no dependency edge pointing back --
6
+ and it is why this belongs in a shared library rather than in whichever consumer
7
+ happened to need it first.
8
+
9
+ Alongside it is the conversion every state dict of any size ends up needing:
10
+ enums as dict keys, because JSON keys must be strings.
11
+
12
+ Conversions for a *particular* type live with that type instead --
13
+ `cooptools.expertise.expertise_state` is the worked example. What those functions
14
+ know is which of an expertise calculator's fields holds the truth and which are
15
+ derived; that they happen to end up as JSON is incidental, and grouping them here
16
+ would put them beside enum conversion, which they have nothing in common with.
17
+
18
+ What is deliberately *not* here: anything about where state is written. A file
19
+ format, a directory of saves, a version envelope around a whole document -- those
20
+ are decisions about an application, not about an object handing over its state.
21
+ """
22
+ from cooptools.persistence.stateful import (
23
+ StateDict,
24
+ StateError,
25
+ StateVersionError,
26
+ Stateful,
27
+ VERSION_KEY,
28
+ check_version,
29
+ version_of,
30
+ )
31
+ from cooptools.persistence.enum_state import (
32
+ enum_from_state,
33
+ enum_keyed_from_state,
34
+ enum_keyed_to_state,
35
+ enum_to_state,
36
+ )
@@ -0,0 +1,81 @@
1
+ """Enums across the JSON boundary.
2
+
3
+ Most state blocks of any size carry at least one enum-keyed
4
+ quantity map -- what a stockpile holds, what a villager carries, what a recipe
5
+ costs -- and JSON has no enum and no non-string key. Left to each call site, that
6
+ translation gets written seven ways: by `.value` in one place and `.name` in
7
+ another, crashing on an unknown member here and silently dropping it there.
8
+
9
+ **Names, not values.** A member's `name` is the thing the rest of the codebase
10
+ already treats as its stable identity (log lines, config keys, file keys). Its
11
+ `value` is frequently `auto()`, which renumbers the moment somebody inserts a
12
+ member -- so a file written by value would read back as a *different* resource
13
+ after an unrelated edit, with nothing to report it.
14
+ """
15
+ import logging
16
+ from enum import Enum
17
+ from typing import Dict, Mapping, Optional, Type, TypeVar
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ E = TypeVar('E', bound=Enum)
22
+
23
+
24
+ def enum_to_state(value: Optional[Enum]) -> Optional[str]:
25
+ """One enum member as its name, passing None through."""
26
+ return value.name if value is not None else None
27
+
28
+
29
+ def enum_from_state(name: Optional[str], enum_cls: Type[E], *,
30
+ owner: str = None) -> Optional[E]:
31
+ """One enum member back from its name.
32
+
33
+ An unknown name yields None with a warning rather than raising. A member
34
+ removed from the code between the write and the read is a real, expected event
35
+ during development, and it should cost the field rather than the file --
36
+ every caller already handles None for these (a region with no building, a
37
+ site with no target tier).
38
+ """
39
+ if name is None:
40
+ return None
41
+ try:
42
+ return enum_cls[name]
43
+ except KeyError:
44
+ logger.warning(f"{owner or enum_cls.__name__}: '{name}' is not a member of "
45
+ f"{enum_cls.__name__} any more -- dropped")
46
+ return None
47
+
48
+
49
+ def enum_keyed_to_state(quantities: Optional[Mapping[Enum, float]]) -> Dict[str, float]:
50
+ """An enum-keyed quantity map as a name-keyed one.
51
+
52
+ Written in the enum's own declaration order rather than the mapping's, so two
53
+ writes of the same state are byte-identical -- which is what lets a round-trip
54
+ test compare dicts, and lets two files diff usefully.
55
+ """
56
+ if not quantities:
57
+ return {}
58
+ members = sorted(quantities.keys(), key=lambda member: list(type(member)).index(member))
59
+ return {member.name: quantities[member] for member in members}
60
+
61
+
62
+ def enum_keyed_from_state(quantities: Optional[Mapping[str, float]],
63
+ enum_cls: Type[E], *,
64
+ owner: str = None) -> Dict[E, float]:
65
+ """A name-keyed quantity map back to an enum-keyed one.
66
+
67
+ Unknown names are dropped with a warning, for the reason `enum_from_state`
68
+ gives -- but here the loss is quantified in the log, since goods vanishing
69
+ from a stockpile is the kind of thing that otherwise gets noticed as a
70
+ balance mystery three sessions later.
71
+ """
72
+ if not quantities:
73
+ return {}
74
+ result: Dict[E, float] = {}
75
+ for name, qty in quantities.items():
76
+ try:
77
+ result[enum_cls[name]] = qty
78
+ except KeyError:
79
+ logger.warning(f"{owner or enum_cls.__name__}: dropping {qty:g} of '{name}', "
80
+ f"which is not a member of {enum_cls.__name__} any more")
81
+ return result
@@ -0,0 +1,148 @@
1
+ """The contract every persistable thing implements: hand out your state, and
2
+ take it back.
3
+
4
+ Deliberately a `typing.Protocol` rather than a base class, because conformance is
5
+ *structural*: a class satisfies this by having the two methods, without importing
6
+ anything from here. That is what lets a class in one package be persisted by
7
+ another with no dependency edge pointing back -- which is the whole reason this
8
+ is a library type rather than one belonging to whoever is doing the persisting.
9
+
10
+ ## State is not definition
11
+
12
+ The split this protocol exists to enforce: **state is what use changed;
13
+ definition is what built the object.** A production station is built from its
14
+ recipe -- inputs, outputs, a timer callback, a schedule -- and *holds* the
15
+ contents of its stores and how far its current run has got. Only the second half
16
+ belongs in a state dict. The first half is reconstructed by whatever built the
17
+ object the first time, from the same source it used then.
18
+
19
+ Getting this line wrong in either direction is expensive. Persisting definition
20
+ means a restored object silently pins yesterday's configuration and stops
21
+ tracking the file it should be reading. Persisting too little means the object
22
+ comes back subtly wrong, usually in a way nothing notices for a while.
23
+
24
+ ## Why `apply_state` mutates instead of `from_state` constructing
25
+
26
+ Almost nothing worth persisting can be rebuilt from a dict alone. The objects
27
+ that carry interesting state are usually constructed with injected collaborators
28
+ -- providers, callbacks, the graph they read -- none of which survive a round trip
29
+ through JSON. A `from_state` classmethod would force every one of them to grow a
30
+ second construction path whose only caller is the loader.
31
+
32
+ `apply_state` matches how a restore actually goes instead: the object graph is
33
+ rebuilt the ordinary way, so everything exists with its collaborators already
34
+ wired, and the saved state is then poured into objects that are otherwise ready to
35
+ run.
36
+
37
+ A leaf that genuinely owns nothing may additionally offer a `from_state`
38
+ classmethod as a convenience. The protocol does not ask for one.
39
+
40
+ ## Versioning
41
+
42
+ Each block carries its own `STATE_VERSION`, stamped under `VERSION_KEY`, and each
43
+ `apply_state` is responsible for reading older shapes of its own block. Versioning
44
+ per class rather than once for the whole file is what stops a new field on one
45
+ class invalidating every file in existence -- and it keeps a migration next to the
46
+ invariant it is migrating, which is the same reason the state lives on the object
47
+ rather than in a central writer.
48
+ """
49
+ import logging
50
+ from typing import Any, ClassVar, Dict, Protocol, runtime_checkable
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # A JSON-safe mapping: dicts, lists, strings, numbers, bools and None, and
55
+ # nothing else. Enum keys are written as `.name` (see enum_state.py), and any
56
+ # identity-bearing type -- a uuid, a graph Node -- is written as whatever stable
57
+ # name addresses it, never as the object.
58
+ StateDict = Dict[str, Any]
59
+
60
+ # Where a block's own version lives. Short because it appears once per block, and
61
+ # a file of any size holds a great many blocks.
62
+ VERSION_KEY = "v"
63
+
64
+
65
+ class StateError(ValueError):
66
+ """A state dict could not be read."""
67
+
68
+
69
+ class StateVersionError(StateError):
70
+ """A state dict is a version this build does not understand."""
71
+
72
+
73
+ @runtime_checkable
74
+ class Stateful(Protocol):
75
+ """Hands out its own state, and takes it back.
76
+
77
+ Two things to know about checking conformance against this at runtime:
78
+
79
+ - **`isinstance` works and covers all three members**, `STATE_VERSION`
80
+ included, so a class that has the methods but never declared a version is
81
+ correctly refused.
82
+ - **`issubclass` raises `TypeError`** on every Python version, because this
83
+ protocol has a non-method member. Check an instance, never a class.
84
+
85
+ And what `isinstance` does *not* buy: `runtime_checkable` compares member
86
+ presence, never signatures. It catches a conformer that dropped or renamed a
87
+ member -- the realistic failure for a class in another package that never
88
+ imports this -- and says nothing about one that changed an argument.
89
+ """
90
+
91
+ STATE_VERSION: ClassVar[int]
92
+
93
+ def to_state(self) -> StateDict:
94
+ """This object's play-state, as JSON-safe primitives.
95
+
96
+ Must include `VERSION_KEY`. Must not include anything reconstructed by
97
+ whatever builds this object -- see the module docstring on state vs.
98
+ definition.
99
+ """
100
+ ...
101
+
102
+ def apply_state(self, state: StateDict) -> None:
103
+ """Adopts `state`, replacing whatever this object currently holds.
104
+
105
+ Called on an object that is fully constructed and wired to its
106
+ collaborators. Implementations replace rather than merge: applying a state
107
+ twice must leave the same result as applying it once, and a field absent
108
+ from `state` takes its documented default rather than whatever happened to
109
+ be there.
110
+ """
111
+ ...
112
+
113
+
114
+ def version_of(state: StateDict) -> int:
115
+ """The version stamped on a state block.
116
+
117
+ Raises rather than defaulting: an unstamped block is either corrupt or predates
118
+ versioning, and both are cases where guessing a version reads the rest of the
119
+ block under rules it was not written to.
120
+ """
121
+ if VERSION_KEY not in state:
122
+ raise StateVersionError(
123
+ f"State block carries no '{VERSION_KEY}' -- it is corrupt, or was written "
124
+ f"before this block was versioned")
125
+ version = state[VERSION_KEY]
126
+ if not isinstance(version, int) or isinstance(version, bool):
127
+ raise StateVersionError(
128
+ f"State block's '{VERSION_KEY}' is {version!r}, which is not a version number")
129
+ return version
130
+
131
+
132
+ def check_version(state: StateDict, *, owner: str, supported: int) -> int:
133
+ """Returns the block's version, refusing one this build cannot read.
134
+
135
+ `owner` names the class in the error, because a load failure often reaches a
136
+ user as a single line, and "which part of this file is too new" is the whole of
137
+ what they can act on.
138
+
139
+ A version *below* `supported` is returned rather than refused -- that is the
140
+ case `apply_state` migrates, and this function has no way to know whether it
141
+ can.
142
+ """
143
+ version = version_of(state)
144
+ if version > supported:
145
+ raise StateVersionError(
146
+ f"{owner} state is version {version}, but this build understands up to "
147
+ f"{supported} -- it was written by a newer build")
148
+ return version
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cooptools
3
- Version: 1.76
3
+ Version: 1.78
4
4
  Summary: Generic Tooling
5
5
  Home-page: https://github.com/tylertjburns/cooptools
6
6
  Author: tburns
@@ -61,6 +61,7 @@ cooptools/dataStore/inMemoryDataStore.py
61
61
  cooptools/expertise/__init__.py
62
62
  cooptools/expertise/expertiseArgs.py
63
63
  cooptools/expertise/expertiseSchedules.py
64
+ cooptools/expertise/expertise_state.py
64
65
  cooptools/finance/__init__.py
65
66
  cooptools/finance/dicounted_cashflow_analysis.py
66
67
  cooptools/finance/fund_projection.py
@@ -114,6 +115,9 @@ cooptools/pathing/waypoint.py
114
115
  cooptools/pathing/waypointFollower.py
115
116
  cooptools/pathing/demo/__init__.py
116
117
  cooptools/pathing/demo/server.py
118
+ cooptools/persistence/__init__.py
119
+ cooptools/persistence/enum_state.py
120
+ cooptools/persistence/stateful.py
117
121
  cooptools/physics/__init__.py
118
122
  cooptools/physics/kinematic.py
119
123
  cooptools/physics/kinematic_utils.py
@@ -160,6 +164,7 @@ tests/test_coopEnum.py
160
164
  tests/test_currency.py
161
165
  tests/test_curves.py
162
166
  tests/test_date_utils.py
167
+ tests/test_expertise_state.py
163
168
  tests/test_flow_field.py
164
169
  tests/test_flow_field_perf.py
165
170
  tests/test_fund_projection.py
@@ -169,6 +174,7 @@ tests/test_kinematic_utils.py
169
174
  tests/test_os_manip.py
170
175
  tests/test_pandas.py
171
176
  tests/test_path_utils.py
177
+ tests/test_persistence.py
172
178
  tests/test_printing.py
173
179
  tests/test_qualifiers.py
174
180
  tests/test_sectors.py
@@ -7,7 +7,7 @@ with open('requirements.txt') as f:
7
7
  requirements = f.read().splitlines()
8
8
 
9
9
  setuptools.setup(name='cooptools',
10
- version='1.76',
10
+ version='1.78',
11
11
  description='Generic Tooling',
12
12
  url='https://github.com/tylertjburns/cooptools',
13
13
  author='tburns',
@@ -0,0 +1,116 @@
1
+ import unittest
2
+ from decimal import Decimal
3
+ from cooptools.currency import USD
4
+ class TestUSD(unittest.TestCase):
5
+ def test_usd_initialization(self):
6
+ usd = USD(5, 150)
7
+ self.assertEqual(usd.dollars, 6)
8
+ self.assertEqual(usd.cents, 50)
9
+
10
+ def test_usd_from_val(self):
11
+ self.assertEqual(USD.from_val(5.75), USD(5, 75))
12
+ self.assertEqual(USD.from_val("3.20"), USD(3, 20))
13
+ self.assertEqual(USD.from_val(2), USD(2, 0))
14
+
15
+ def test_usd_addition(self):
16
+ self.assertEqual(USD(3, 50) + USD(2, 75), USD(6, 25))
17
+ self.assertEqual(USD(1, 90) + 0.15, USD(2, 5))
18
+
19
+ def test_usd_subtraction(self):
20
+ self.assertEqual(USD(5, 50) - USD(2, 25), USD(3, 25))
21
+ self.assertEqual(USD(2, 10) - 1.05, USD(1, 5))
22
+
23
+ def test_usd_multiplication(self):
24
+ self.assertEqual(USD(3, 50) * 2, USD(7, 0))
25
+ self.assertEqual(USD(2, 25) * 3, USD(6, 75))
26
+
27
+ def test_usd_division(self):
28
+ self.assertEqual(USD(5, 0) / 2, USD(2, 50))
29
+ self.assertAlmostEqual(USD(10, 0) / USD(2, 0), 5.0)
30
+
31
+ def test_usd_division__by_usd_yields_a_float_ratio(self):
32
+ ratio = USD(10, 0) / USD(4, 0)
33
+ self.assertIsInstance(ratio, float)
34
+ self.assertEqual(ratio * 2.0, 5.0)
35
+
36
+ def test_usd_from_val__every_cent_round_trips(self):
37
+ """ Values whose binary float lands just under the cent boundary (0.29 -> 28.999...)
38
+ must not truncate down a cent."""
39
+ mismatches = [total_cents / 100
40
+ for total_cents in range(-100000, 100001)
41
+ if USD.from_val(total_cents / 100).AmountAsDecimal != Decimal(total_cents) / 100]
42
+ self.assertEqual(mismatches[:10], [], f"{len(mismatches)} values did not round-trip")
43
+
44
+ def test_usd_from_val__known_truncation_cases(self):
45
+ for val, expected in [(0.29, USD(0, 29)),
46
+ (0.57, USD(0, 57)),
47
+ (1.13, USD(1, 13)),
48
+ (2.01, USD(2, 1)),
49
+ (48.15, USD(48, 15))]:
50
+ with self.subTest(val=val):
51
+ self.assertEqual(USD.from_val(val), expected)
52
+
53
+ def test_usd_from_val__negative(self):
54
+ self.assertEqual(USD.from_val(-0.29), USD(0, -29))
55
+ self.assertEqual(USD.from_val(-48.15), USD(-48, -15))
56
+ self.assertEqual(USD.from_val(-48.15).AmountAsDecimal, Decimal("-48.15"))
57
+
58
+ def test_usd_from_val__decimal_and_usd_passthrough(self):
59
+ self.assertEqual(USD.from_val(Decimal("12.34")), USD(12, 34))
60
+ self.assertEqual(USD.from_val(USD(12, 34)), USD(12, 34))
61
+
62
+ def test_usd_from_val__rejects_bool(self):
63
+ self.assertRaises(TypeError, lambda: USD.from_val(True))
64
+
65
+ def test_usd_normalization__equality_and_hash_agree_with_value(self):
66
+ self.assertEqual(USD(0, 100), USD(1, 0))
67
+ self.assertEqual(hash(USD(0, 100)), hash(USD(1, 0)))
68
+ self.assertEqual(USD(1, -150), USD(0, -50))
69
+ self.assertEqual(hash(USD(1, -150)), hash(USD(0, -50)))
70
+
71
+ def test_usd_normalization__sign_is_consistent_across_both_fields(self):
72
+ neg = USD(0, -150)
73
+ self.assertEqual(neg.dollars, -1)
74
+ self.assertEqual(neg.cents, -50)
75
+
76
+ def test_usd_subtraction__crossing_below_negative_one_dollar(self):
77
+ self.assertEqual(USD(1, 0) - USD(2, 50), USD(-1, -50))
78
+ self.assertEqual((USD(1, 0) - USD(2, 50)).AmountAsDecimal, Decimal("-1.50"))
79
+
80
+ def test_usd_amount_as_decimal__is_exact_and_two_places(self):
81
+ self.assertEqual(USD(48, 15).AmountAsDecimal, Decimal("48.15"))
82
+ self.assertEqual(str(USD(1, 0).AmountAsDecimal), "1.00")
83
+
84
+ def test_usd_accumulation_is_exact(self):
85
+ """ The float-accumulation drift that motivated this type: bare float += lands on
86
+ 107.60999999999999, so a request to move 107.61 reads as more than the balance."""
87
+ total = USD.zero()
88
+ for v in [15.04, 33.11, 22.83, 36.63]:
89
+ total = total + v
90
+ self.assertEqual(total, USD.from_val(107.61))
91
+ self.assertFalse(USD.from_val(107.61) > total)
92
+
93
+ def test_usd_format(self):
94
+ self.assertEqual("{:,.2f}".format(USD(1234, 56)), "1,234.56")
95
+ self.assertEqual("{}".format(USD(1234, 56)), "$1,234.56")
96
+ self.assertEqual(f"{USD(1234, 56)}", "$1,234.56")
97
+
98
+ def test_usd_bool(self):
99
+ self.assertFalse(USD.zero())
100
+ self.assertTrue(USD(0, 1))
101
+
102
+ def test_usd_comparisons(self):
103
+ self.assertTrue(USD(1, 0) > USD(0, 99))
104
+ self.assertTrue(USD(0, 99) < USD(1, 0))
105
+ self.assertTrue(USD(1, 0) >= USD(1, 0))
106
+ self.assertTrue(USD(1, 0) <= USD(1, 0))
107
+ self.assertTrue(USD(0, -50) < USD.zero())
108
+
109
+ def test_usd_rsub(self):
110
+ self.assertEqual(5.00 - USD(2, 25), USD(2, 75))
111
+
112
+ def test_usd_abs(self):
113
+ self.assertEqual(abs(USD(-1, -50)), USD(1, 50))
114
+
115
+ if __name__ == "__main__":
116
+ unittest.main()