Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Let it live independently from Satchless

  • Loading branch information...
commit 6ebabbc682f462a11b1a81935b692e6864060bde 0 parents
@patrys patrys authored
5 .gitignore
@@ -0,0 +1,5 @@
+.*
+!.gitignore
+!.travis.*
+*.py[co]
+*~
9 .travis.yml
@@ -0,0 +1,9 @@
+language: python
+python:
+ - 2.6
+ - 2.7
+ - 3.2
+script: nosetests
+install:
+ - pip install nose
+ - python setup.py install
33 README.rst
@@ -0,0 +1,33 @@
+Prices: Python price handling for humans
+========================================
+
+::
+
+ >>> from prices import price, pricerange, lineartax
+ >>> p = price('1.99')
+ >>> p += price(50)
+ >>> p += lineartax('1.23', '20% VAT')
+ >>> p.quantize('0.01').gross
+ Decimal('63.95')
+ >>> pr = pricerange(price(50), price(100))
+ >>> p in pr
+ True
+
+While protecting you from all sorts of mistakes::
+
+ >>> from prices import price
+ >>> price(10, currency='USD') < price(15, currency='GBP')
+ ...
+ ValueError: Cannot compare prices in 'USD' and 'GBP'
+ >>> price(5, currency='BTC') + price(7, currency='INR')
+ ...
+ ValueError: Cannot add price in 'BTC' to 'INR'
+
+And being helpful::
+
+ >>> from prices import price, lineartax, inspect_price
+ >>> p = price('1.99')
+ >>> p += price(50)
+ >>> p += lineartax('1.23', '20% VAT')
+ >>> inspect_price(p)
+ "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 @@
+from decimal import Decimal
+import operator
+
+__version__ = '2012.9.0'
+
+
+class price(object):

Why the lower-case class names?

@patrys Owner
patrys added a note

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ gross = Decimal('NaN')
+ gross_base = Decimal('NaN')
+ net = Decimal('NaN')
+ net_base = Decimal('NaN')
+ currency = None
+
+ def __init__(self, net, gross=None, currency=None, previous=None,
+ modifier=None, operation=None):
+ self.net = Decimal(net)
+ if gross is not None:
+ self.gross = Decimal(gross)
+ else:
+ self.gross = self.net
+ self.currency = currency
+ self.previous = previous
+ self.modifier = modifier
+ self.operation = operation
+
+ def __repr__(self):
+ if self.net == self.gross:
+ return 'price(%r, currency=%r)' % (self.net, self.currency)
+ return ('price(net=%r, gross=%r, currency=%r)' %
+ (self.net, self.gross, self.currency))
+
+ def __lt__(self, other):
+ if isinstance(other, price):
+ if self.currency != other.currency:
+ raise ValueError('Cannot compare prices in %r and %r' %
+ (self.currency, other.currency))
+ return self.gross < other.gross
+ return NotImplemented
+
+ def __le__(self, other):
+ return self < other or self == other
+
+ def __eq__(self, other):
+ if isinstance(other, price):
+ return (self.gross == other.gross and
+ self.net == other.net and
+ self.currency == other.currency)
+ return False
+
+ def __ne__(self, other):
+ return not self == other
+
+ def __mul__(self, other):
+ price_net = self.net * other
+ price_gross = self.gross * other
+ return price(net=price_net, gross=price_gross, currency=self.currency,
+ previous=self, modifier=other, operation=operator.__mul__)
+
+ def __add__(self, other):
+ if isinstance(other, pricemodifier):
+ return other.apply(self)
+ if isinstance(other, price):
+ if other.currency != self.currency:
+ raise ValueError('Cannot add price in %r to %r' %
+ (self.currency, other.currency))
+ price_net = self.net + other.net
+ price_gross = self.gross + other.gross
+ return price(net=price_net, gross=price_gross,
+ currency=self.currency, previous=self, modifier=other,
+ operation=operator.__add__)
+ return NotImplemented
+
+ @property
+ def tax(self):
+ return self.gross - self.net
+
+ def quantize(self, exp):
+ exp = Decimal(exp)
+ return price(net=self.net.quantize(exp), gross=self.gross.quantize(exp),
+ currency=self.currency, previous=self, modifier=exp,
+ operation=price.quantize)
+
+ def inspect(self):
+ if self.previous:
+ return (self.previous.inspect(), self.operation, self.modifier)
+ return self
+
+ def elements(self):
+ if not self.previous:
+ return [self]
+ if hasattr(self.modifier, 'elements'):
+ modifiers = self.modifier.elements()
+ else:
+ modifiers = [self.modifier]
+ return self.previous.elements() + modifiers
+
+
+class pricerange(object):
+
+ min_price = None
+ max_price = None
+
+ def __init__(self, min_price, max_price=None):
+ self.min_price = min_price
+ if max_price is None:
+ max_price = min_price
+ if min_price > max_price:
+ raise ValueError('Cannot create a pricerange from %r to %r' %
+ (min_price, max_price))
+ if min_price.currency != max_price.currency:
+ raise ValueError('Cannot create a pricerange as %r and %r use'
+ ' different currencies' % (min_price, max_price))
+ self.max_price = max_price
+
+ def __repr__(self):
+ if self.max_price == self.min_price:
+ return 'pricerange(%r)' % (self.min_price,)
+ return ('pricerange(%r, %r)' %
+ (self.min_price, self.max_price))
+
+ def __add__(self, other):
+ if isinstance(other, pricemodifier):
+ return pricerange(min_price=other.apply(self.min_price),
+ max_price=other.apply(self.max_price))
+ if isinstance(other, price):
+ if other.currency != self.min_price.currency:
+ raise ValueError("Cannot add pricerange in %r to price in %r" %
+ (self.min_price.currency, other.currency))
+ min_price = self.min_price + other
+ max_price = self.max_price + other
+ return pricerange(min_price=min_price, max_price=max_price)
+ elif isinstance(other, pricerange):
+ if other.min_price.currency != self.min_price.currency:
+ raise ValueError('Cannot add priceranges in %r and %r' %
+ (self.min_price.currency,
+ other.min_price.currency))
+ min_price = self.min_price + other.min_price
+ max_price = self.max_price + other.max_price
+ return pricerange(min_price=min_price, max_price=max_price)
+ return NotImplemented
+
+ def __eq__(self, other):
+ if isinstance(other, pricerange):
+ return (self.min_price == other.min_price and
+ self.max_price == other.max_price)
+ return False
+
+ def __ne__(self, other):
+ return not self == other
+
+ def __contains__(self, item):
+ if not isinstance(item, price):
+ raise TypeError('in <pricerange> requires price as left operand,'
+ ' not %s' % (type(item),))
+ return self.min_price <= item <= self.max_price
+
+ def replace(self, min_price=None, max_price=None):
+ '''
+ Return a new pricerange object with one or more properties set to
+ values passed to this method.
+ '''
+ if min_price is None:
+ min_price = self.min_price
+ if max_price is None:
+ max_price = self.max_price
+ return pricerange(min_price=min_price, max_price=max_price)
+
+
+class pricemodifier(object):
+
+ name = None
+ net = Decimal('0')
+ gross = Decimal('0')
+
+ def apply(self, price):
+ raise NotImplementedError()
+
+
+class tax(pricemodifier):
+ '''
+ A generic tax class, provided so all taxers have a common base.
+ '''
+ name = None
+
+ def apply(self, price_obj):
+ return price(net=price_obj.net,
+ gross=self.calculate_gross(price_obj),
+ currency=price_obj.currency,
+ previous=price_obj,
+ modifier=self,
+ operation=operator.__add__)
+
+ def calculate_gross(self, price_obj):
+ raise NotImplementedError()
+
+
+class lineartax(tax):
+ '''
+ A linear tax, modifies .
+ '''
+ def __init__(self, multiplier, name=None):
+ self.multiplier = Decimal(multiplier)
+ self.name = name or self.name
+
+ def __repr__(self):
+ return 'lineartax(%r, name=%r)' % (self.multiplier, self.name)
+
+ def __lt__(self, other):
+ if not isinstance(other, lineartax):
+ raise TypeError('Cannot compare lineartax to %r' % (other,))
+ return self.multiplier < other.multiplier
+
+ def __eq__(self, other):
+ if isinstance(other, lineartax):
+ return (self.multiplier == other.multiplier and
+ self.name == other.name)
+ return False
+
+ def __ne__(self, other):
+ return not self == other
+
+ def calculate_gross(self, price_obj):
+ return price_obj.gross * self.multiplier
+
+
+def inspect_price(price_obj):
+ def format_inspect(data):
+ if isinstance(data, tuple):
+ op1, op, op2 = data
+ if op is operator.__mul__:
+ return '(%s) * %r' % (format_inspect(op1), op2)
+ if op == price.quantize:
+ return '(%s).quantize(%r)' % (format_inspect(op1), op2)
+ return '%s + %s' % (format_inspect(op1), format_inspect(op2))
+ return repr(data)
+ return format_inspect(price_obj.inspect())
127 prices/tests/test_prices.py
@@ -0,0 +1,127 @@
+import decimal
+import unittest
+
+from prices import price, pricerange, lineartax, inspect_price
+
+
+class PriceTest(unittest.TestCase):
+
+ def setUp(self):
+ self.ten_btc = price(10, currency='BTC')
+ self.twenty_btc = price(20, currency='BTC')
+ self.thirty_dollars = price(30, currency='USD')
+
+ def test_basics(self):
+ self.assertEqual(self.ten_btc.net, self.ten_btc.gross)
+
+ def test_adding_non_price_object_fails(self):
+ self.assertRaises(TypeError, lambda p: p + 10, self.ten_btc)
+
+ def test_multiplication(self):
+ p1 = self.ten_btc * 5
+ self.assertEqual(p1.net, 50)
+ self.assertEqual(p1.gross, 50)
+ p2 = self.ten_btc * 5
+ self.assertEqual(p1, p2)
+
+ def test_valid_comparison(self):
+ self.assertLess(self.ten_btc, self.twenty_btc)
+ self.assertGreater(self.twenty_btc, self.ten_btc)
+
+ def test_invalid_comparison(self):
+ self.assertRaises(TypeError, lambda: self.ten_btc < 3)
+ self.assertRaises(ValueError,
+ lambda: self.ten_btc < self.thirty_dollars)
+
+ def test_valid_addition(self):
+ p = self.ten_btc + self.twenty_btc
+ self.assertEqual(p.net, 30)
+ self.assertEqual(p.gross, 30)
+
+ def test_invalid_addition(self):
+ self.assertRaises(ValueError,
+ lambda: self.ten_btc + self.thirty_dollars)
+
+ def test_tax(self):
+ tax = lineartax(2, name='2x Tax')
+ p = self.ten_btc + tax
+ self.assertEqual(p.net, self.ten_btc.net)
+ self.assertEqual(p.gross, self.ten_btc.gross * 2)
+ self.assertEqual(p.currency, self.ten_btc.currency)
+
+ def test_inspect(self):
+ tax = lineartax('1.2345678', name='Silly Tax')
+ p = ((self.ten_btc + self.twenty_btc) * 5 + tax).quantize('0.01')
+ self.assertEqual(
+ inspect_price(p),
+ "((price(Decimal('10'), currency='BTC') + price(Decimal('20'), currency='BTC')) * 5 + lineartax(Decimal('1.2345678'), name='Silly Tax')).quantize(Decimal('0.01'))")
+
+ def test_elements(self):
+ tax = lineartax('1.2345678', name='Silly Tax')
+ p = ((self.ten_btc + self.twenty_btc) * 5 + tax).quantize('0.01')
+ self.assertEqual(
+ p.elements(),
+ [self.ten_btc, self.twenty_btc, 5, tax, decimal.Decimal('0.01')])
+
+
+class PriceRangeTest(unittest.TestCase):
+
+ def setUp(self):
+ self.ten_btc = price(10, currency='BTC')
+ self.twenty_btc = price(20, currency='BTC')
+ self.thirty_btc = price(30, currency='BTC')
+ self.forty_btc = price(40, currency='BTC')
+ self.range_ten_twenty = pricerange(self.ten_btc, self.twenty_btc)
+ self.range_thirty_forty = pricerange(self.thirty_btc, self.forty_btc)
+
+ def test_basics(self):
+ self.assertEqual(self.range_ten_twenty.min_price, self.ten_btc)
+ self.assertEqual(self.range_ten_twenty.max_price, self.twenty_btc)
+
+ def test_valid_addition(self):
+ pr1 = self.range_ten_twenty + self.range_thirty_forty
+ self.assertEqual(pr1.min_price, self.ten_btc + self.thirty_btc)
+ self.assertEqual(pr1.max_price, self.twenty_btc + self.forty_btc)
+ pr2 = self.range_ten_twenty + self.ten_btc
+ self.assertEqual(pr2.min_price, self.ten_btc + self.ten_btc)
+ self.assertEqual(pr2.max_price, self.twenty_btc + self.ten_btc)
+
+ def test_invalid_addition(self):
+ self.assertRaises(TypeError, lambda: self.range_ten_twenty + 10)
+
+ def test_valid_membership(self):
+ '''
+ Prices can fit in a pricerange.
+ '''
+ self.assertTrue(self.ten_btc in self.range_ten_twenty)
+ self.assertTrue(self.twenty_btc in self.range_ten_twenty)
+ self.assertFalse(self.thirty_btc in self.range_ten_twenty)
+
+ def test_invalid_membership(self):
+ '''
+ Non-prices can't fit in a pricerange.
+ '''
+ self.assertRaises(TypeError, lambda: 15 in self.range_ten_twenty)
+
+ def test_replacement(self):
+ pr1 = self.range_ten_twenty.replace(max_price=self.thirty_btc)
+ self.assertEqual(pr1.min_price, self.ten_btc)
+ self.assertEqual(pr1.max_price, self.thirty_btc)
+ pr2 = self.range_thirty_forty.replace(min_price=self.twenty_btc)
+ self.assertEqual(pr2.min_price, self.twenty_btc)
+ self.assertEqual(pr2.max_price, self.forty_btc)
+
+ def test_tax(self):
+ tax_name = '2x Tax'
+ tax = lineartax(2, name=tax_name)
+ pr = self.range_ten_twenty + tax
+ self.assertEqual(pr.min_price.net, self.ten_btc.net)
+ self.assertEqual(pr.min_price.gross, self.ten_btc.gross * 2)
+ self.assertEqual(pr.min_price.currency, self.ten_btc.currency)
+ self.assertEqual(pr.max_price.net, self.twenty_btc.net)
+ self.assertEqual(pr.max_price.gross, self.twenty_btc.gross * 2)
+ self.assertEqual(pr.max_price.currency, self.twenty_btc.currency)
+
+
+if __name__ == '__main__':
+ unittest.main()
30 setup.py
@@ -0,0 +1,30 @@
+#! /usr/bin/env python
+import prices
+
+from setuptools import setup
+
+CLASSIFIERS = [
+ 'Development Status :: 5 - Production/Stable',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2.5',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3.2',
+ 'Programming Language :: Python :: 3.3',
+ 'Topic :: Software Development :: Libraries :: Python Modules',
+]
+
+setup(name='prices',
+ author='Mirumee Software',
+ author_email='hello@mirumee.com',
+ description='Python price handling for humans',
+ license='BSD',
+ version=prices.__version__,
+ url='http://satchless.com/',
+ packages=['prices'],
+ include_package_data=True,
+ classifiers=CLASSIFIERS,
+ platforms=['any'])
Please sign in to comment.
Something went wrong with that request. Please try again.