Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Let it live independently from Satchless

  • Loading branch information...
commit 6ebabbc682f462a11b1a81935b692e6864060bde 0 parents
Patryk Zawadzki authored September 11, 2012
5  .gitignore
... ...
@@ -0,0 +1,5 @@
  1
+.*
  2
+!.gitignore
  3
+!.travis.*
  4
+*.py[co]
  5
+*~
9  .travis.yml
... ...
@@ -0,0 +1,9 @@
  1
+language: python
  2
+python:
  3
+  - 2.6
  4
+  - 2.7
  5
+  - 3.2
  6
+script: nosetests
  7
+install:
  8
+  - pip install nose
  9
+  - python setup.py install
33  README.rst
Source Rendered
... ...
@@ -0,0 +1,33 @@
  1
+Prices: Python price handling for humans
  2
+========================================
  3
+
  4
+::
  5
+
  6
+    >>> from prices import price, pricerange, lineartax
  7
+    >>> p = price('1.99')
  8
+    >>> p += price(50)
  9
+    >>> p += lineartax('1.23', '20% VAT')
  10
+    >>> p.quantize('0.01').gross
  11
+    Decimal('63.95')
  12
+    >>> pr = pricerange(price(50), price(100))
  13
+    >>> p in pr
  14
+    True
  15
+
  16
+While protecting you from all sorts of mistakes::
  17
+
  18
+    >>> from prices import price
  19
+    >>> price(10, currency='USD') < price(15, currency='GBP')
  20
+    ...
  21
+    ValueError: Cannot compare prices in 'USD' and 'GBP'
  22
+    >>> price(5, currency='BTC') + price(7, currency='INR')
  23
+    ...
  24
+    ValueError: Cannot add price in 'BTC' to 'INR'
  25
+
  26
+And being helpful::
  27
+
  28
+    >>> from prices import price, lineartax, inspect_price
  29
+    >>> p = price('1.99')
  30
+    >>> p += price(50)
  31
+    >>> p += lineartax('1.23', '20% VAT')
  32
+    >>> inspect_price(p)
  33
+    "price(Decimal('1.99'), currency=None) + price(Decimal('50'), currency=None) + lineartax(Decimal('1.23'), name='20% VAT')"
236  prices/__init__.py
... ...
@@ -0,0 +1,236 @@
  1
+from decimal import Decimal
  2
+import operator
  3
+
  4
+__version__ = '2012.9.0'
  5
+
  6
+
  7
+class price(object):
  8
+    gross = Decimal('NaN')
  9
+    gross_base = Decimal('NaN')
  10
+    net = Decimal('NaN')
  11
+    net_base = Decimal('NaN')
  12
+    currency = None
  13
+
  14
+    def __init__(self, net, gross=None, currency=None, previous=None,
  15
+                 modifier=None, operation=None):
  16
+        self.net = Decimal(net)
  17
+        if gross is not None:
  18
+            self.gross = Decimal(gross)
  19
+        else:
  20
+            self.gross = self.net
  21
+        self.currency = currency
  22
+        self.previous = previous
  23
+        self.modifier = modifier
  24
+        self.operation = operation
  25
+
  26
+    def __repr__(self):
  27
+        if self.net == self.gross:
  28
+            return 'price(%r, currency=%r)' % (self.net, self.currency)
  29
+        return ('price(net=%r, gross=%r, currency=%r)' %
  30
+                (self.net, self.gross, self.currency))
  31
+
  32
+    def __lt__(self, other):
  33
+        if isinstance(other, price):
  34
+            if self.currency != other.currency:
  35
+                raise ValueError('Cannot compare prices in %r and %r' %
  36
+                                 (self.currency, other.currency))
  37
+            return self.gross < other.gross
  38
+        return NotImplemented
  39
+
  40
+    def __le__(self, other):
  41
+        return self < other or self == other
  42
+
  43
+    def __eq__(self, other):
  44
+        if isinstance(other, price):
  45
+            return (self.gross == other.gross and
  46
+                    self.net == other.net and
  47
+                    self.currency == other.currency)
  48
+        return False
  49
+
  50
+    def __ne__(self, other):
  51
+        return not self == other
  52
+
  53
+    def __mul__(self, other):
  54
+        price_net = self.net * other
  55
+        price_gross = self.gross * other
  56
+        return price(net=price_net, gross=price_gross, currency=self.currency,
  57
+                     previous=self, modifier=other, operation=operator.__mul__)
  58
+
  59
+    def __add__(self, other):
  60
+        if isinstance(other, pricemodifier):
  61
+            return other.apply(self)
  62
+        if isinstance(other, price):
  63
+            if other.currency != self.currency:
  64
+                raise ValueError('Cannot add price in %r to %r' %
  65
+                                 (self.currency, other.currency))
  66
+            price_net = self.net + other.net
  67
+            price_gross = self.gross + other.gross
  68
+            return price(net=price_net, gross=price_gross,
  69
+                         currency=self.currency, previous=self, modifier=other,
  70
+                         operation=operator.__add__)
  71
+        return NotImplemented
  72
+
  73
+    @property
  74
+    def tax(self):
  75
+        return self.gross - self.net
  76
+
  77
+    def quantize(self, exp):
  78
+        exp = Decimal(exp)
  79
+        return price(net=self.net.quantize(exp), gross=self.gross.quantize(exp),
  80
+                     currency=self.currency, previous=self, modifier=exp,
  81
+                     operation=price.quantize)
  82
+
  83
+    def inspect(self):
  84
+        if self.previous:
  85
+            return (self.previous.inspect(), self.operation, self.modifier)
  86
+        return self
  87
+
  88
+    def elements(self):
  89
+        if not self.previous:
  90
+            return [self]
  91
+        if hasattr(self.modifier, 'elements'):
  92
+            modifiers = self.modifier.elements()
  93
+        else:
  94
+            modifiers = [self.modifier]
  95
+        return self.previous.elements() + modifiers
  96
+
  97
+
  98
+class pricerange(object):
  99
+
  100
+    min_price = None
  101
+    max_price = None
  102
+
  103
+    def __init__(self, min_price, max_price=None):
  104
+        self.min_price = min_price
  105
+        if max_price is None:
  106
+            max_price = min_price
  107
+        if min_price > max_price:
  108
+            raise ValueError('Cannot create a pricerange from %r to %r' %
  109
+                             (min_price, max_price))
  110
+        if min_price.currency != max_price.currency:
  111
+            raise ValueError('Cannot create a pricerange as %r and %r use'
  112
+                             ' different currencies' % (min_price, max_price))
  113
+        self.max_price = max_price
  114
+
  115
+    def __repr__(self):
  116
+        if self.max_price == self.min_price:
  117
+            return 'pricerange(%r)' % (self.min_price,)
  118
+        return ('pricerange(%r, %r)' %
  119
+                (self.min_price, self.max_price))
  120
+
  121
+    def __add__(self, other):
  122
+        if isinstance(other, pricemodifier):
  123
+            return pricerange(min_price=other.apply(self.min_price),
  124
+                              max_price=other.apply(self.max_price))
  125
+        if isinstance(other, price):
  126
+            if other.currency != self.min_price.currency:
  127
+                raise ValueError("Cannot add pricerange in %r to price in %r" %
  128
+                                 (self.min_price.currency, other.currency))
  129
+            min_price = self.min_price + other
  130
+            max_price = self.max_price + other
  131
+            return pricerange(min_price=min_price, max_price=max_price)
  132
+        elif isinstance(other, pricerange):
  133
+            if other.min_price.currency != self.min_price.currency:
  134
+                raise ValueError('Cannot add priceranges in %r and %r' %
  135
+                                 (self.min_price.currency,
  136
+                                  other.min_price.currency))
  137
+            min_price = self.min_price + other.min_price
  138
+            max_price = self.max_price + other.max_price
  139
+            return pricerange(min_price=min_price, max_price=max_price)
  140
+        return NotImplemented
  141
+
  142
+    def __eq__(self, other):
  143
+        if isinstance(other, pricerange):
  144
+            return (self.min_price == other.min_price and
  145
+                    self.max_price == other.max_price)
  146
+        return False
  147
+
  148
+    def __ne__(self, other):
  149
+        return not self == other
  150
+
  151
+    def __contains__(self, item):
  152
+        if not isinstance(item, price):
  153
+            raise TypeError('in <pricerange> requires price as left operand,'
  154
+                            ' not %s' % (type(item),))
  155
+        return self.min_price <= item <= self.max_price
  156
+
  157
+    def replace(self, min_price=None, max_price=None):
  158
+        '''
  159
+        Return a new pricerange object with one or more properties set to
  160
+        values passed to this method.
  161
+        '''
  162
+        if min_price is None:
  163
+            min_price = self.min_price
  164
+        if max_price is None:
  165
+            max_price = self.max_price
  166
+        return pricerange(min_price=min_price, max_price=max_price)
  167
+
  168
+
  169
+class pricemodifier(object):
  170
+
  171
+    name = None
  172
+    net = Decimal('0')
  173
+    gross = Decimal('0')
  174
+
  175
+    def apply(self, price):
  176
+        raise NotImplementedError()
  177
+
  178
+
  179
+class tax(pricemodifier):
  180
+    '''
  181
+    A generic tax class, provided so all taxers have a common base.
  182
+    '''
  183
+    name = None
  184
+
  185
+    def apply(self, price_obj):
  186
+        return price(net=price_obj.net,
  187
+                     gross=self.calculate_gross(price_obj),
  188
+                     currency=price_obj.currency,
  189
+                     previous=price_obj,
  190
+                     modifier=self,
  191
+                     operation=operator.__add__)
  192
+
  193
+    def calculate_gross(self, price_obj):
  194
+        raise NotImplementedError()
  195
+
  196
+
  197
+class lineartax(tax):
  198
+    '''
  199
+    A linear tax, modifies .
  200
+    '''
  201
+    def __init__(self, multiplier, name=None):
  202
+        self.multiplier = Decimal(multiplier)
  203
+        self.name = name or self.name
  204
+
  205
+    def __repr__(self):
  206
+        return 'lineartax(%r, name=%r)' % (self.multiplier, self.name)
  207
+
  208
+    def __lt__(self, other):
  209
+        if not isinstance(other, lineartax):
  210
+            raise TypeError('Cannot compare lineartax to %r' % (other,))
  211
+        return self.multiplier < other.multiplier
  212
+
  213
+    def __eq__(self, other):
  214
+        if isinstance(other, lineartax):
  215
+            return (self.multiplier == other.multiplier and
  216
+                    self.name == other.name)
  217
+        return False
  218
+
  219
+    def __ne__(self, other):
  220
+        return not self == other
  221
+
  222
+    def calculate_gross(self, price_obj):
  223
+        return price_obj.gross * self.multiplier
  224
+
  225
+
  226
+def inspect_price(price_obj):
  227
+    def format_inspect(data):
  228
+        if isinstance(data, tuple):
  229
+            op1, op, op2 = data
  230
+            if op is operator.__mul__:
  231
+                return '(%s) * %r' % (format_inspect(op1), op2)
  232
+            if op == price.quantize:
  233
+                return '(%s).quantize(%r)' % (format_inspect(op1), op2)
  234
+            return '%s + %s' % (format_inspect(op1), format_inspect(op2))
  235
+        return repr(data)
  236
+    return format_inspect(price_obj.inspect())
127  prices/tests/test_prices.py
... ...
@@ -0,0 +1,127 @@
  1
+import decimal
  2
+import unittest
  3
+
  4
+from prices import price, pricerange, lineartax, inspect_price
  5
+
  6
+
  7
+class PriceTest(unittest.TestCase):
  8
+
  9
+    def setUp(self):
  10
+        self.ten_btc = price(10, currency='BTC')
  11
+        self.twenty_btc = price(20, currency='BTC')
  12
+        self.thirty_dollars = price(30, currency='USD')
  13
+
  14
+    def test_basics(self):
  15
+        self.assertEqual(self.ten_btc.net, self.ten_btc.gross)
  16
+
  17
+    def test_adding_non_price_object_fails(self):
  18
+        self.assertRaises(TypeError, lambda p: p + 10, self.ten_btc)
  19
+
  20
+    def test_multiplication(self):
  21
+        p1 = self.ten_btc * 5
  22
+        self.assertEqual(p1.net, 50)
  23
+        self.assertEqual(p1.gross, 50)
  24
+        p2 = self.ten_btc * 5
  25
+        self.assertEqual(p1, p2)
  26
+
  27
+    def test_valid_comparison(self):
  28
+        self.assertLess(self.ten_btc, self.twenty_btc)
  29
+        self.assertGreater(self.twenty_btc, self.ten_btc)
  30
+
  31
+    def test_invalid_comparison(self):
  32
+        self.assertRaises(TypeError, lambda: self.ten_btc < 3)
  33
+        self.assertRaises(ValueError,
  34
+                          lambda: self.ten_btc < self.thirty_dollars)
  35
+
  36
+    def test_valid_addition(self):
  37
+        p = self.ten_btc + self.twenty_btc
  38
+        self.assertEqual(p.net, 30)
  39
+        self.assertEqual(p.gross, 30)
  40
+
  41
+    def test_invalid_addition(self):
  42
+        self.assertRaises(ValueError,
  43
+                          lambda: self.ten_btc + self.thirty_dollars)
  44
+
  45
+    def test_tax(self):
  46
+        tax = lineartax(2, name='2x Tax')
  47
+        p = self.ten_btc + tax
  48
+        self.assertEqual(p.net, self.ten_btc.net)
  49
+        self.assertEqual(p.gross, self.ten_btc.gross * 2)
  50
+        self.assertEqual(p.currency, self.ten_btc.currency)
  51
+
  52
+    def test_inspect(self):
  53
+        tax = lineartax('1.2345678', name='Silly Tax')
  54
+        p = ((self.ten_btc + self.twenty_btc) * 5 + tax).quantize('0.01')
  55
+        self.assertEqual(
  56
+            inspect_price(p),
  57
+            "((price(Decimal('10'), currency='BTC') + price(Decimal('20'), currency='BTC')) * 5 + lineartax(Decimal('1.2345678'), name='Silly Tax')).quantize(Decimal('0.01'))")
  58
+
  59
+    def test_elements(self):
  60
+        tax = lineartax('1.2345678', name='Silly Tax')
  61
+        p = ((self.ten_btc + self.twenty_btc) * 5 + tax).quantize('0.01')
  62
+        self.assertEqual(
  63
+            p.elements(),
  64
+            [self.ten_btc, self.twenty_btc, 5, tax, decimal.Decimal('0.01')])
  65
+
  66
+
  67
+class PriceRangeTest(unittest.TestCase):
  68
+
  69
+    def setUp(self):
  70
+        self.ten_btc = price(10, currency='BTC')
  71
+        self.twenty_btc = price(20, currency='BTC')
  72
+        self.thirty_btc = price(30, currency='BTC')
  73
+        self.forty_btc = price(40, currency='BTC')
  74
+        self.range_ten_twenty = pricerange(self.ten_btc, self.twenty_btc)
  75
+        self.range_thirty_forty = pricerange(self.thirty_btc, self.forty_btc)
  76
+
  77
+    def test_basics(self):
  78
+        self.assertEqual(self.range_ten_twenty.min_price, self.ten_btc)
  79
+        self.assertEqual(self.range_ten_twenty.max_price, self.twenty_btc)
  80
+
  81
+    def test_valid_addition(self):
  82
+        pr1 = self.range_ten_twenty + self.range_thirty_forty
  83
+        self.assertEqual(pr1.min_price, self.ten_btc + self.thirty_btc)
  84
+        self.assertEqual(pr1.max_price, self.twenty_btc + self.forty_btc)
  85
+        pr2 = self.range_ten_twenty + self.ten_btc
  86
+        self.assertEqual(pr2.min_price, self.ten_btc + self.ten_btc)
  87
+        self.assertEqual(pr2.max_price, self.twenty_btc + self.ten_btc)
  88
+
  89
+    def test_invalid_addition(self):
  90
+        self.assertRaises(TypeError, lambda: self.range_ten_twenty + 10)
  91
+
  92
+    def test_valid_membership(self):
  93
+        '''
  94
+        Prices can fit in a pricerange.
  95
+        '''
  96
+        self.assertTrue(self.ten_btc in self.range_ten_twenty)
  97
+        self.assertTrue(self.twenty_btc in self.range_ten_twenty)
  98
+        self.assertFalse(self.thirty_btc in self.range_ten_twenty)
  99
+
  100
+    def test_invalid_membership(self):
  101
+        '''
  102
+        Non-prices can't fit in a pricerange.
  103
+        '''
  104
+        self.assertRaises(TypeError, lambda: 15 in self.range_ten_twenty)
  105
+
  106
+    def test_replacement(self):
  107
+        pr1 = self.range_ten_twenty.replace(max_price=self.thirty_btc)
  108
+        self.assertEqual(pr1.min_price, self.ten_btc)
  109
+        self.assertEqual(pr1.max_price, self.thirty_btc)
  110
+        pr2 = self.range_thirty_forty.replace(min_price=self.twenty_btc)
  111
+        self.assertEqual(pr2.min_price, self.twenty_btc)
  112
+        self.assertEqual(pr2.max_price, self.forty_btc)
  113
+
  114
+    def test_tax(self):
  115
+        tax_name = '2x Tax'
  116
+        tax = lineartax(2, name=tax_name)
  117
+        pr = self.range_ten_twenty + tax
  118
+        self.assertEqual(pr.min_price.net, self.ten_btc.net)
  119
+        self.assertEqual(pr.min_price.gross, self.ten_btc.gross * 2)
  120
+        self.assertEqual(pr.min_price.currency, self.ten_btc.currency)
  121
+        self.assertEqual(pr.max_price.net, self.twenty_btc.net)
  122
+        self.assertEqual(pr.max_price.gross, self.twenty_btc.gross * 2)
  123
+        self.assertEqual(pr.max_price.currency, self.twenty_btc.currency)
  124
+
  125
+
  126
+if __name__ == '__main__':
  127
+    unittest.main()
30  setup.py
... ...
@@ -0,0 +1,30 @@
  1
+#! /usr/bin/env python
  2
+import prices
  3
+
  4
+from setuptools import setup
  5
+
  6
+CLASSIFIERS = [
  7
+    'Development Status :: 5 - Production/Stable',
  8
+    'Intended Audience :: Developers',
  9
+    'License :: OSI Approved :: BSD License',
  10
+    'Operating System :: OS Independent',
  11
+    'Programming Language :: Python',
  12
+    'Programming Language :: Python :: 2.5',
  13
+    'Programming Language :: Python :: 2.6',
  14
+    'Programming Language :: Python :: 2.7',
  15
+    'Programming Language :: Python :: 3.2',
  16
+    'Programming Language :: Python :: 3.3',
  17
+    'Topic :: Software Development :: Libraries :: Python Modules',
  18
+]
  19
+
  20
+setup(name='prices',
  21
+      author='Mirumee Software',
  22
+      author_email='hello@mirumee.com',
  23
+      description='Python price handling for humans',
  24
+      license='BSD',
  25
+      version=prices.__version__,
  26
+      url='http://satchless.com/',
  27
+      packages=['prices'],
  28
+      include_package_data=True,
  29
+      classifiers=CLASSIFIERS,
  30
+      platforms=['any'])

0 notes on commit 6ebabbc

David Winterbottom

Why the lower-case class names?

Patryk Zawadzki

I followed datetime's example. They behave like opaque objects you rarely call any methods on.

Please sign in to comment.
Something went wrong with that request. Please try again.