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.
- {cooptools-1.76 → cooptools-1.78}/PKG-INFO +1 -1
- cooptools-1.78/cooptools/currency.py +154 -0
- cooptools-1.78/cooptools/expertise/__init__.py +6 -0
- cooptools-1.78/cooptools/expertise/expertise_state.py +58 -0
- cooptools-1.78/cooptools/persistence/__init__.py +36 -0
- cooptools-1.78/cooptools/persistence/enum_state.py +81 -0
- cooptools-1.78/cooptools/persistence/stateful.py +148 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/PKG-INFO +1 -1
- {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/SOURCES.txt +6 -0
- {cooptools-1.76 → cooptools-1.78}/setup.py +1 -1
- cooptools-1.78/tests/test_currency.py +116 -0
- cooptools-1.78/tests/test_expertise_state.py +120 -0
- cooptools-1.78/tests/test_persistence.py +176 -0
- cooptools-1.76/cooptools/currency.py +0 -134
- cooptools-1.76/cooptools/expertise/__init__.py +0 -2
- cooptools-1.76/tests/test_currency.py +0 -31
- {cooptools-1.76 → cooptools-1.78}/MANIFEST.in +0 -0
- {cooptools-1.76 → cooptools-1.78}/README.md +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/anchor.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/asyncable.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/cli/CliAtomicUserInteraction.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/cli/CliMenu.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/cli/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/cli/fileContentReturn.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/cnxn_info.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/colors.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/commandController.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/commandProtocol.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/commandDesignPattern/exceptions.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/common.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/config.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/coopDataclass.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/coopEnum.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/coopthreading.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataRefresher/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataRefresher/dataHub.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataRefresher/dataRefresher.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/dataProcessor.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/dataStoreProtocol.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/dbConnectionURI.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dataStore/inMemoryDataStore.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/date_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/decay.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/decor.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/dictPolicies.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/exceptions.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/expertise/expertiseArgs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/expertise/expertiseSchedules.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/dicounted_cashflow_analysis.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/fund_projection.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/fund_projection_renderer.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/futureValueProjector.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/growth_projections.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/reports.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/finance/utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/circle_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/common.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/curve_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/curves.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/line_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/polygon_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/rect_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/triangle_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/geometry_utils/vector_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/astar_results_viewer.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/draw.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/flow_field.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/graph.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/graph_dcs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/graph_definitions.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/graphs/utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/actionItemTracker/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/actionItemTracker/ai_tracker.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/actionItemTracker/dcs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/discreteEventSimulator/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/discreteEventSimulator/discreteEventSimulator.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/jsonIO.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/metrics.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/triggerActionSystem/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/triggerActionSystem/signal.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/ideas/triggerActionSystem/trigger.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/loggingHelpers.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/marchingSquares.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/cli.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/dcs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/materialHandling/selectionCriteria.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/matrixManipulation.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/os_manip.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pandasHelpers.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/catmullRomFollower.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/demo/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/demo/server.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/kinematicGoalSeeker.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/motion_profile_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/orbit_fuzzer.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/path_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/straightLineFollower.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/trajectory_provider.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/waypoint.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/pathing/waypointFollower.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/physics/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/physics/kinematic.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/physics/kinematic_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/physics/movement.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/physics/trajectory.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/plotting.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/printing.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/protocols.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/qualifiers/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/qualifiers/cli.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/qualifiers/qualifier.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/randoms.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/register.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/dcs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/enums.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/reservation/reservationmanager.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/retry.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/gridState.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/grid_base.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/hexGrid.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/grids/rectGrid.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/hex_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/sect_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/sectorTree/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/sectors/sectorTree/sectorTree.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/activityReport.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/controlChart/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/controlChart/controlChart.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/controlChart/plotting.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/oee/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/statistics/oee/oeeHub.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/taskProcessing/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/taskProcessing/dcs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/taskProcessing/taskProcessor.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/timeTracker/__init__.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/timeTracker/decay.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/timeTracker/timeTracker.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/timeWindow.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/toggles.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/transform.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/trends.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/typeProviders.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/typevalidation.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools/version.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/dependency_links.txt +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/not-zip-safe +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/requires.txt +0 -0
- {cooptools-1.76 → cooptools-1.78}/cooptools.egg-info/top_level.txt +0 -0
- {cooptools-1.76 → cooptools-1.78}/requirements.txt +0 -0
- {cooptools-1.76 → cooptools-1.78}/setup.cfg +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_CliAtomicUserInteraction.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_asyncable.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_catmullRomFollower.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_cdp_bugs.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_commandDesignPattern.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_common.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_config.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_coopEnum.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_curves.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_date_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_flow_field.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_flow_field_perf.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_fund_projection.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_graph_node_classification.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_kinematicGoalSeeker.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_kinematic_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_os_manip.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_pandas.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_path_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_printing.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_qualifiers.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_sectors.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_sectors_hex.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_sectors_sectorTree.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_statistics.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_straightLineFollower.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_target_seek.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_tasks.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_toggles.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_trajectory.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_trends.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_typevalidation.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_vector_utils.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_version.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/test_waypointFollower.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/tests_graph.py +0 -0
- {cooptools-1.76 → cooptools-1.78}/tests/tests_gridsystem.py +0 -0
|
@@ -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,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
|
|
@@ -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
|
|
@@ -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()
|